blog/database/connection-pooling-exhaustion
Database Design & Internals

Connection Pooling Gone Wrong: Exhausting Your Database Connections

Your database has plenty of CPU and memory to spare, but requests are timing out with 'too many connections' or hanging forever waiting for a pool slot. The pool that was supposed to make you fast is now the bottleneck. Here's why pools exhaust, and how to size them without guessing.

ยท9 min read

The Outage With No Obvious Cause

Traffic spikes. Suddenly half your requests are failing. You check the database: CPU is at 30%, memory is fine, disk is quiet. The database is bored. Yet your application logs are full of timeout acquiring connection from pool and remaining connection slots are reserved.

The database can handle the work. Your application just can't get a line to it. This is connection pool exhaustion, and it's one of the most common ways a healthy database causes an unhealthy application.

To understand why it happens, you first have to understand why the pool exists at all.


Why Pools Exist

Opening a database connection is expensive. It's not just a TCP handshake โ€” the database forks or assigns a backend process, authenticates, negotiates encryption, and sets up session state. In PostgreSQL, every connection is a full OS process with its own memory. Doing that per request, at thousands of requests per second, would flatten your database before any real query ran.

๐Ÿ“Œ Connection Pool

A pool keeps a set of already-open connections alive and hands them out to requests that need them. A request borrows a connection, runs its queries, and returns it โ€” it doesn't open or close anything. The expensive setup happens once, at startup, and gets amortized across every request that reuses the connection.

Cost of Getting a Connection
Open a fresh connection30ms
Borrow from a warm pool1ms

That's the win: skip the handshake, reuse the process. But a pool has a fixed number of slots. And that is where exhaustion begins.


How a Pool Gets Exhausted

A pool of size 20 can hand out 20 connections at once. Request 21 has to wait until someone gives one back. If it waits longer than the acquire-timeout, it fails.

Here's the path a request takes:

Borrowing From the Pool
Request
needs a conn
acquire
Pool
20 slots
Wait Queue
if full
Database
runs query

Exhaustion means every slot is occupied and the wait queue is backing up. The math is unforgiving. If each request holds a connection for 100ms and your pool has 20 slots, your ceiling is roughly 200 requests/second โ€” no matter how idle the database looks. Push past that and requests pile into the wait queue until they time out.

The dangerous part: the symptom (pool exhausted) is almost never the cause. Something is holding connections longer than it should. Find that, and the exhaustion evaporates.


The Four Ways Pools Actually Exhaust

1. Slow queries hold slots hostage

A connection is held for the entire time a query runs. One unindexed query that takes 5 seconds ties up its slot for 5 seconds. Twenty of those in flight and the whole pool is frozen โ€” while the CPU sits idle, because the queries are waiting on disk or locks, not compute.

โš ๏ธ Pool exhaustion is often a slow-query problem in disguise

Before you enlarge the pool, look at query latency. A pool that exhausts under load is frequently telling you that a handful of queries got slow โ€” a missing index, a lock, a table that grew. Fixing the query returns connections to the pool faster than adding slots ever could.

2. Leaked connections never come back

If your code borrows a connection and an exception fires before it's returned, that slot is gone until something reclaims it. Do that on an error path that triggers under load, and the pool bleeds slots exactly when you need them most.

// LEAK: if the query throws, release() never runs
const conn = await pool.acquire();
const rows = await conn.query(sql);   // throws here...
conn.release();                        // ...and this never executes
 
// SAFE: release in finally, always
const conn = await pool.acquire();
try {
  return await conn.query(sql);
} finally {
  conn.release();   // runs on success AND on error
}

Most modern clients wrap this for you, but hand-rolled connection handling and long-lived transactions are classic leak sources.

3. Holding a connection across a network call

This one is subtle and brutal. You grab a connection, and while holding it, you call an external API:

const conn = await pool.acquire();
const user = await conn.query("SELECT * FROM users WHERE id = $1", [id]);
const enriched = await fetch(externalApi, ...);  // <-- 800ms, holding the conn!
await conn.query("UPDATE users SET ...", ...);
conn.release();

That connection is held for the full 800ms of the HTTP call, doing nothing at the database. Under load, your pool empties waiting on a third party. The fix: do the external call outside the connection's borrow window. Only hold a connection when you're actually talking to the database.

4. Too many app instances, all with their own pool

You scale horizontally to 50 app instances, each with a pool of 20. That's a demand for 1,000 connections against a database configured for 100. Every instance thinks it has headroom; collectively they overwhelm the database.

๐Ÿ”ด Pools are per-process, limits are global

Your database's max_connections is a single shared ceiling. Your pool size is per instance. The number that matters is pool_size ร— instance_count โ€” and it's easy to blow past the database limit without any single service looking greedy. When you autoscale, connection demand scales with it.


Sizing the Pool: Bigger Is Not Better

The instinct when a pool exhausts is to make it bigger. Usually wrong. Every open connection consumes database memory and adds scheduling overhead. Past a point, more connections make the database slower, because it's context-switching between hundreds of processes contending for the same CPUs and locks.

โœ… The counterintuitive rule

A smaller pool often yields higher throughput. Fewer connections means less contention inside the database, so each query finishes faster, so connections return to the pool sooner. Many teams cut pool size and watched latency drop.

A widely-cited starting point (from PostgreSQL performance work) for a CPU-bound workload:

pool_size = (core_count ร— 2) + effective_spindle_count

For an 8-core server with SSDs, that lands near 17โ€“20 connections per node โ€” far smaller than most people guess. It's a starting point, not gospel: measure under real load and adjust. The goal is the smallest pool that keeps your cores busy without a growing wait queue.

SymptomLikely CauseFirst Move
Exhausted, DB CPU lowSlow queries or external calls holding slotsProfile query time; move I/O outside borrow window
Exhausted, DB CPU highPool too large, DB thrashingReduce pool size, add read replicas
Slots vanish over timeConnection leak on an error pathAudit release() / use finally; check long txns
'Too many connections' errorpool_size ร— instances > max_connectionsAdd a proxy (PgBouncer) or cut per-node pool

When One Pool Isn't Enough: Connection Proxies

Once you have many app instances, per-process pools stop scaling โ€” the global connection count outruns the database. The standard answer is an external pooler like PgBouncer sitting between your apps and the database.

A Proxy Multiplexes Many Clients Onto Few DB Connections
few real connsApp 1App 2App 3PgBouncerpoolerDatabasemax 100

The apps open cheap connections to PgBouncer, which multiplexes thousands of them onto a small set of real database connections. In transaction pooling mode, a real connection is only tied to a client for the duration of a single transaction, so a handful of database connections can serve enormous client concurrency. The catch: transaction-mode pooling breaks session-level features (prepared statements, session variables, LISTEN/NOTIFY), so you have to know what your app relies on.


Key Takeaways

  • A pool amortizes the real cost of opening connections, but it has a fixed number of slots โ€” request N+1 waits, and if the wait exceeds the timeout, it fails.
  • Exhaustion is almost always a symptom: slow queries, leaks, connections held across network calls, or too many instances โ€” not a genuine need for more slots.
  • Never hold a connection while doing non-database work (external APIs, heavy CPU). Borrow late, release early, always in a finally.
  • Bigger pools often hurt. Aim for the smallest pool that keeps your cores busy; measure, don't guess. (cores ร— 2) + spindles is a sane starting point.
  • Remember the global ceiling: pool_size ร— instances must fit under the database's max_connections. Beyond that scale, put a proxy like PgBouncer in front.

References