PgBouncer vs pgpool-II vs Supavisor for mobile APIs
Meta description: Compare PgBouncer, pgpool-II, and Supavisor for mobile backends. Survive push-notification traffic spikes and connection exhaustion without over-engineering your stack.
Tags: backend api mobile architecture microservices
TL;DR
Mobile traffic is hostile to naive Postgres connection handling. App launches, push-notification floods, and aggressive OS sleep cycles create burst patterns that will exhaust max_connections in seconds on an untuned stack. PgBouncer in transaction mode is the battle-tested default; Supavisor wins for multi-tenant SaaS; pgpool-II is overkill for most mobile backends unless you need read replicas baked in. Pool sizing is a formula, not a guess.
The mobile traffic problem
In my experience building production systems that serve mobile clients, the connection profile looks nothing like a traditional web backend. Three distinct burst shapes keep showing up:
- Cold app launches. Users opening the app after overnight sleep hit your backend in a synchronized wave. Everyone woke up, got a notification from some other service, and now they’re checking yours.
- Push-notification spikes. A single FCM/APNs broadcast can generate tens of thousands of simultaneous API calls within 30 seconds. I’ve watched this take down well-provisioned servers.
- Foreground/background cycling. iOS and Android aggressively kill and restore network state, creating connection churn that long-lived session pools cannot absorb.
A max_connections = 200 Postgres instance with no pooler will fall over under a modest push to 50k devices. Each idle Postgres backend consumes roughly 5–10 MB of RAM and holds a worker process. At 200 connections you’ve already allocated 1–2 GB just for connection overhead before a single query runs.
The contenders
PgBouncer
Lightweight C daemon, single-threaded event loop. Ships in every Linux package manager and runs on under 2 MB of RAM.
Three modes, but only one matters for mobile. Session mode gives each client one server connection for the duration — equivalent to no pooling, so don’t use it. Transaction mode releases the server connection after each transaction completes; this is what you want. Statement mode releases after every statement, which breaks multi-statement transactions and is rarely useful.
; pgbouncer.ini — transaction mode config
[pgbouncer]
pool_mode = transaction
max_client_conn = 5000
default_pool_size = 25
reserve_pool_size = 5
reserve_pool_timeout = 3
server_idle_timeout = 600
One real gotcha with transaction mode: it historically broke named prepared statements because the underlying server connection changes between transactions. PgBouncer 1.21 fixed this with protocol-level prepared statement tracking — no ini key required, just upgrade. If you’re stuck on an older release, configure your driver to use unnamed prepared statements (e.g., prepareThreshold=0 in pgjdbc or prepared_statement_cache_queries=0 in asyncpg), or fall back to the simple query protocol. See the PgBouncer 1.21 changelog for specifics.
pgpool-II
Feature-rich middleware: connection pooling, query load balancing across replicas, in-memory query cache, and replication management. It does a lot — which is the problem.
For mobile backends that need only connection pooling, pgpool-II adds unnecessary complexity: a heavier process model, more failure modes, and query routing logic that misbehaves with ORMs. Reserve it for architectures that genuinely need transparent read/write splitting.
Supavisor
Elixir-based pooler built by Supabase, designed for multi-tenant SaaS. Each tenant gets an isolated pool, preventing one noisy mobile client from starving another. Runs as a cluster-aware service rather than a per-node daemon.
The tradeoff is real: operationally heavier than PgBouncer (requires an Erlang/OTP runtime), but the isolation model is genuinely better when your backend serves multiple independent organizations.
Comparison table
| Dimension | PgBouncer | pgpool-II | Supavisor |
|---|---|---|---|
| Resource footprint | ~2 MB RAM | ~50–100 MB | ~50–200 MB (cluster) |
| Transaction mode | Yes | Yes | Yes |
| Multi-tenant isolation | No | No | Yes (per-tenant pools) |
| Prepared statements (tx mode) | 1.21+ native; older needs unnamed stmts | Partial | Full support |
| Read replica routing | No | Yes | Partial¹ |
| Operational complexity | Low | High | Medium |
| Best fit | Single-tenant APIs | HA + replica routing | Multi-tenant SaaS |
¹ Supavisor supports read replica routing but without the query-level parse analysis that pgpool-II performs. Routing decisions are made at the connection level based on explicit client hints rather than automatic read/write detection — sufficient for many workloads, but verify against your ORM’s behavior before relying on it.
Pool sizing math
Most teams set default_pool_size by feel rather than formula. That’s how you end up either starving Postgres or leaving capacity on the table.
The pool size should come from Postgres capacity, not from expected client count. For a single-database deployment:
pool_size = (postgres_max_connections × 0.8) / number_of_pgbouncer_instances
The 0.8 factor reserves headroom for superuser connections and monitoring. For a max_connections = 200 Postgres with two PgBouncer nodes:
pool_size = (200 × 0.8) / 2 = 80 per node
Multi-database caveat: PgBouncer maintains pools per (database, user) pair, not as a global shared allocation. If your backend connects to multiple databases through the same PgBouncer instance, budget
pool_sizeindependently per database — dividing by instance count alone will underestimate total server connections.
For push-notification burst capacity, set max_client_conn to your 99th-percentile concurrent connection estimate, not your average. A reserve_pool_size of 10–15% of default_pool_size absorbs the initial spike while the pool warms.
What to do
-
Default to PgBouncer 1.21+ in transaction mode for any mobile backend not serving multiple tenants. The protocol-level prepared statement support in 1.21 eliminates the most common transaction-mode integration headache — upgrade before reaching for workarounds.
-
Size pools from the database outward, not the client inward. Calculate
default_pool_sizefrommax_connections, apply the 0.8 headroom factor, then account for per-database pool budgets before dividing across instances. Validatemax_client_connagainst your push-notification spike envelope. -
Adopt Supavisor only when tenant isolation is a hard requirement. The operational cost is justified for multi-tenant SaaS; it’s unnecessary complexity for single-product mobile backends.