Cloud FinOps & Infrastructure ScalingPlaybook3 min readUpdated September 2026

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.

Executive Capability Standard

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)

1. Learn:Check your database's current connection count against its max_connections setting during both normal and peak traffic to see how much headroom actually exists.
2. Do Manually:Manually cap the per-app-server connection pool size as a stopgap so a scaling event can't multiply your total connection count unexpectedly.
3. Delegate:Have a backend engineer own installing and tuning a connection pooler like PgBouncer between your app servers and database.
4. Automate:Add pooler wait-time and connection-count metrics to your monitoring dashboards so pool sizing gets adjusted from data, not guesswork.
5. Buy:Bring in a database or infrastructure specialist if you're already running a pooler and still seeing exhaustion, since the cause is likely elsewhere, like long-running transactions or replica lag.

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