Postgres does not have a connection problem. It has a process problem. Every connection forks a dedicated backend process on the server, and that process keeps its own catalog cache, its own memory contexts, and its own slot in shared memory whether it is executing a query or sitting idle behind a keep-alive.
That design is fine when a monolith opens 40 connections and holds them for the life of the deploy. It stops being fine the moment your API runs on autoscaled containers, each with a driver pool of 20, or on serverless functions that open a connection per invocation. Twelve containers at 20 connections each is 240 backends against a max_connections of 200, and the failure mode is FATAL: sorry, too many clients already — arriving during the exact traffic spike that triggered the scale-out.
The three things people call "connection pooling"
These get conflated constantly, and they solve different halves of the problem.
Driver-side pooling is what HikariCP, pgxpool, node-postgres Pool, SQLAlchemy's QueuePool, and Django's CONN_MAX_AGE give you. It bounds and reuses connections within one process. Nothing coordinates across processes, so the cluster-wide total is whatever your replica count multiplies out to. This is usually what people mean when they say pooling is "built in" — it is built into the client library, not the server. Core PostgreSQL still ships no server-side pooler in a released version; proposals have circulated for years without landing.
An external pooler — PgBouncer, Supavisor, pgcat — sits between your app and Postgres as a proxy, accepting many client connections and multiplexing them onto a much smaller set of server connections.
A provider pooler is the same thing operated by someone else: RDS Proxy, Azure's managed PgBouncer, Supabase's hosted Supavisor, Neon's proxy layer.
The important part: these compose rather than compete. The driver pool bounds concurrency per process and saves you the TCP plus TLS plus auth handshake on every query. The proxy bounds the total across the fleet. Removing the driver pool because you added PgBouncer just moves connection churn onto the pooler.
Your ceiling is
max_connectionsminussuperuser_reserved_connections, minus autovacuum workers, minus replication slots, minus whatever your migration job and your ownpsqlsession need. Size the pooler's server-side pool against what is left, not againstmax_connectionsitself. On the client side, the useful target is roughly your active core count times a small factor — a pool of 200 against 8 cores does not produce more throughput, it produces more context switching and a longer p99.
PgBouncer and Supavisor solve the same problem at different scales
PgBouncer is a small C daemon built around a single-threaded event loop. Footprint is tens of megabytes. It offers three pooling modes — session, transaction, and statement — and transaction mode is where the multiplexing payoff lives. Two changes in recent years matter: it can run several processes behind SO_REUSEPORT to use more than one core, and it can track protocol-level prepared statements in transaction mode via max_prepared_statements, which removed the single biggest reason ORMs used to break behind it.
What PgBouncer does not do: it is not cluster-aware, it has no read-replica routing, and configuration is a flat ini file you deploy and reload yourself. That is a feature if you want a boring, well-understood binary next to your database, and a limitation if the pooler itself becomes the tier you need to scale.
Supavisor is Supabase's pooler, written in Elixir on the BEAM. Two design choices drive everything else. It is multi-tenant — one deployment fronts many databases, with the tenant identified from the connection string username — and it runs as a distributed cluster, so the pooler tier scales horizontally instead of vertically. It also does query load balancing across read replicas and can pause a tenant's traffic for a failover or migration without dropping client sockets. The trade-off is a heavier runtime, and on Supabase you consume it as a hosted endpoint (transaction mode on port 6543, session mode and direct connections on 5432) rather than something you hand-tune.
pgcat is the third option worth knowing: Rust, sharding and load balancing built in, smaller community and less production mileage than either of the above.
Transaction mode is the part that breaks your app
Transaction pooling hands the server connection back at COMMIT. Anything scoped to a session rather than a transaction either leaks into an unrelated request or disappears from under you.
SETstatements issued outside a transaction,LISTEN/NOTIFY, session-level advisory locks (pg_advisory_lock, as opposed to the_xact_variants),WITH HOLDcursors, and temp tables all assume a stable session. Prisma needs prepared statements disabled or the PgBouncer flag set; Django's own docs point you atDISABLE_SERVER_SIDE_CURSORSwhen running behind a transaction pooler. Keep migrations,pg_dump, and interactivepsqlon the direct or session-mode port — a schema migration through a transaction pooler is an outage waiting for a schedule.
The tedious half of that migration is finding every call site. Session-state dependencies hide in raw SQL strings, in ORM escape hatches, and in a SET statement_timeout someone added to one service three years ago. Grepping for SET, LISTEN, pg_advisory_lock, and CREATE TEMP gets you most of the way; reading each hit in context is the slow part.
Measure the right thing before and after
Start with the question of whether you have a pooling problem at all. Run SELECT state, count(*) FROM pg_stat_activity GROUP BY state. If idle dwarfs active, connections are being held rather than used, and a pooler will help. If idle in transaction is your largest bucket, a pooler will not save you — that is application code holding a transaction open across a network call, and putting a proxy in front of it just moves the pileup one hop.
After you deploy the pooler, the two numbers that matter come from PgBouncer's admin console. SHOW POOLS gives you cl_waiting and maxwait: a sustained non-zero maxwait means clients are queueing at the proxy, so you have relocated the queue rather than removed it — either the server pool is too small or your queries are too slow. SHOW STATS gives you avg_xact_time alongside avg_query_time; a wide gap between them is the signature of transactions held open around application work.
At the app layer, track p99 latency, not throughput. A pooler is a deliberate trade — a small queueing delay in exchange for not falling over at the connection ceiling — and throughput charts hide that cost while p99 shows it honestly.
Originally published at pickuma.com. Subscribe to the RSS or follow @pickuma.bsky.social for new reviews.
Top comments (0)