What Actually Happens When Your Database Runs Out of Connections
Picture a fairly ordinary morning: traffic ticks up, your autoscaler adds a few more application servers to handle it, and within minutes your database starts throwing connection errors. The instinct is to add even more app servers to handle the errors. That instinct is exactly backwards, and understanding why is the fastest way to actually fix it.
This is what connection exhaustion looks like from the inside, and what a connection pooler changes about the picture.
Why More App Servers Made It Worse, Not Better
A database like Postgres allocates a fixed maximum number of connections, often a few hundred by default, and each one holds real memory and a real process on the database server. Every application server in your fleet typically opens its own pool of connections, so your total connection count scales with your app server count, not with your actual query load. Add five more app servers during a traffic spike, each opening twenty connections, and you've just asked the database for a hundred more connections at the exact moment it's already under load. The database doesn't queue the excess gracefully; it starts rejecting new connections outright.
What a Connection Pooler Actually Does
A pooler like PgBouncer sits between your application servers and the database and maintains a much smaller, fixed pool of real database connections, handing them out to app servers on a per-query or per-transaction basis instead of per-server. Your application code still thinks it's opening a normal connection; the pooler is the one deciding how many actual connections the database sees. This decouples your application server count from your database connection count, which is exactly the coupling that caused the problem in the first place.
The Three Pooling Modes, and Which One You Actually Want
Poolers typically offer a few modes, and picking the wrong one causes subtle bugs:
- Session pooling assigns a connection to a client for its whole session. Safest for compatibility, but barely reduces connection count.
- Transaction pooling assigns a connection only for the duration of a transaction, then returns it to the pool. This is where most of the connection-count savings come from, but it breaks features that rely on session state, like prepared statements or session-level advisory locks, unless your ORM is configured to avoid them.
- Statement pooling returns the connection after every single statement. Most aggressive, most restrictive, rarely the right default.
For most application workloads, transaction pooling is the right starting point, with a careful audit of anything in your ORM or query layer that assumes session state survives between queries.
Sizing the Pool Without Guessing
A pool that's too small just moves the bottleneck from the database to the pooler's own queue. A pool that's too large defeats the purpose. A reasonable starting point is to size the pool close to the number of CPU cores on your database server, since that roughly matches how many queries the server can genuinely execute in parallel; going meaningfully higher rarely improves throughput and just adds contention. From there, watch actual wait times in the pooler's stats and adjust based on measured queueing, not intuition.
What Still Breaks Even With a Pooler in Place
A pooler fixes connection count, not every connection-related problem. Long-running transactions still hold a pooled connection for their entire duration, so a slow report query can still starve the pool for everyone else. Migrations that run DDL against a live table can still lock things up regardless of pooling. And if your read replica lag is already a problem, pooling doesn't touch that at all, it's a separate issue with a separate fix. Treat the pooler as solving exactly one problem: too many raw connections, not as a general database performance fix.
Rolling It Out Without an Outage of Your Own Making
Introducing a pooler into a live system is itself a risk if you do it all at once. Start by pointing a single, low-traffic service at the pooler instead of the database directly, and compare its error rate and latency against the services still connecting directly. Watch specifically for anything that depends on session state: temporary tables, session-level configuration changes, or advisory locks used for something other than job deduplication. Once that first service runs clean for a few days under real traffic, move the next one, and keep a fast rollback path, pointing the connection string back at the database directly, available the whole time.
What Good Looks Like
A healthy connection setup means your database's real connection count stays well under its configured maximum during normal traffic and during a scaling event, with headroom measured and monitored, not assumed.
Building The Capability (5-Stage Skill Ladder)
How to Get Started
Frequently Asked Questions
Does adding a connection pooler mean we can remove our database's own max_connections limit?
No, and you shouldn't want to. The pooler works precisely because it keeps the real connection count under that limit. Removing the limit just brings back the original problem the moment the pooler itself needs to reconnect or during a deploy that temporarily doubles your app server count.
Can we run the pooler on the same server as the application, or does it need its own host?
Either works, but a dedicated pooler instance, or a managed one from your cloud provider, is easier to reason about and monitor separately from application load. Running it embedded per app server tends to recreate some of the original scaling problem, since each app server still manages its own pool.
Why did switching to transaction pooling break our prepared statements?
Prepared statements are tied to a specific database session. Transaction pooling hands connections to different clients between transactions, so a prepared statement created by one client can vanish before another client tries to use it. Most modern ORMs have a setting to disable server-side prepared statements when running behind a transaction-pooled connection.
About the numbers
This guide doesn't quote a sourced benchmark. Figures in it are estimates or general guidance, so check them against your own numbers.
Related Guides
PgBouncer in Production: A Connection Pooling Checklist
Why Postgres runs out of connections before it runs out of CPU, and a rollout checklist for putting PgBouncer in front of it safely.
The Connection Pool Setting That Takes Down Production at 2am
Why connection pools exhaust under load, how PgBouncer's pool modes actually differ, and the four settings worth checking before your next incident.
The Connection Pool Checklist Most Teams Skip Until an Outage
A pre-flight checklist for database connection pooling that catches the pool-exhaustion mistakes most teams only discover during a production outage.
PGBouncer and the Real Limits of Postgres Connection Pooling
Why Postgres connection limits break under load, how PGBouncer's pooling modes actually differ, and the failure modes worth checking for first.
PgBouncer Pool Sizing: A Runbook Before Your Next Deploy Storm
How to size a PgBouncer pool, pick a pooling mode, and stop connection storms during deploys from taking down your database.
The Connection Pooling Setup That Keeps Postgres From Falling Over Under Load
How connection exhaustion actually happens in Postgres, and the PgBouncer configuration that prevents a traffic spike from taking your database down.