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.
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.
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.
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:
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_countFor 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.
| Symptom | Likely Cause | First Move |
|---|---|---|
| Exhausted, DB CPU low | Slow queries or external calls holding slots | Profile query time; move I/O outside borrow window |
| Exhausted, DB CPU high | Pool too large, DB thrashing | Reduce pool size, add read replicas |
| Slots vanish over time | Connection leak on an error path | Audit release() / use finally; check long txns |
| 'Too many connections' error | pool_size ร instances > max_connections | Add 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.
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) + spindlesis a sane starting point. - Remember the global ceiling:
pool_size ร instancesmust fit under the database'smax_connections. Beyond that scale, put a proxy like PgBouncer in front.
References
- About Pool Sizing โ HikariCP wiki โ the definitive argument for small pools, with the formula and benchmarks
- PgBouncer โ Features & Pooling Modes โ how transaction/session/statement pooling differ and what each breaks
- PostgreSQL โ max_connections and resource limits โ the server-side ceiling your pools have to respect