A failure that looks like everything else
Connection pool exhaustion is one of the most common production incidents, and one of the most misdiagnosed. The symptom is generic: requests pile up, latency climbs, the application starts returning timeouts or 500s. The database itself looks healthy — low CPU, plenty of memory, queries running fast. Engineers chase slow queries and missing indexes for hours while the real problem is that the application has run out of connections to send those fast queries through.
Understanding the pool — what it is, why it’s bounded, and how it gets starved — turns a baffling outage into a five-minute diagnosis.
Why connections are pooled and bounded
Opening a database connection is expensive. For PostgreSQL each connection is a separate OS process; for MySQL a thread. Establishing one involves a TCP handshake, TLS negotiation, and authentication — easily several milliseconds, sometimes tens. Doing that per request would dominate latency.
So applications keep a pool of open connections and lease them to requests. A request borrows a connection, runs its queries, and returns it. The pool amortizes the setup cost across thousands of requests.
The pool is bounded for a hard reason: the database has its own connection ceiling. PostgreSQL’s max_connections defaults to around 100. Each connection consumes memory (work_mem allocations, process overhead) regardless of whether it’s busy. Push past the limit and the database refuses new connections or thrashes. The pool’s max_size exists to keep the application a polite tenant of a finite resource.
N app instances × pool max_size ≤ database max_connections (with headroom)
10 instances × 20 = 200 > 100 ← already over-committed
That arithmetic is the root cause of a huge fraction of exhaustion incidents: people size each pool generously, forget to multiply by the number of instances, and oversubscribe the database. Autoscaling makes it worse — scale to 30 instances and you have 600 connections fighting over 100 slots.
The lifecycle of a leased connection
graph TD
A["Request needs a connection"] --> B{"Idle connection available?"}
B -->|Yes| C["Lease it immediately"]
B -->|No| D{"Pool below max_size?"}
D -->|Yes| E["Open new connection, lease it"]
D -->|No| F["Wait in queue up to connectionTimeout"]
F -->|Connection freed| C
F -->|Timeout elapses| G["Throw: pool exhausted"]
C --> H["Run queries"]
H --> I["Return connection to pool"]
I --> B
Exhaustion is the path to G: every connection is leased, the pool is at max_size, and a request waits the full connectionTimeout without one freeing up, then fails. Crucially, the failure happens in the application’s wait queue, not the database. The database never even sees these requests. That’s why database metrics look fine.
The four ways pools get exhausted
1. Connection leaks
A connection is borrowed and never returned. The usual cause is an error path that skips the cleanup:
conn = pool.acquire()
result = conn.execute(query) # raises here...
process(result)
pool.release(conn) # ...so this never runs — leaked
Each leaked connection permanently shrinks the usable pool. Leak slowly and the pool degrades over hours until it hits zero and everything stops. The fix is structural — always release in a finally or, better, a context manager:
with pool.acquire() as conn:
process(conn.execute(query)) # released even on exception
Most pool libraries offer leak detection: log a warning when a connection has been held longer than a threshold (e.g. HikariCP’s leakDetectionThreshold). Turn it on; the stack trace points straight at the offending code.
2. Slow queries holding connections
A connection held for the duration of a query is unavailable to everyone else for that whole time. If a query that normally takes 5 ms degrades to 5 seconds — a missing index after data growth, a lock wait, a stats regression — each in-flight request now occupies a connection 1000x longer. The pool drains almost instantly under load.
This is why slow queries and pool exhaustion are intertwined: the slow query is the cause, exhaustion is the symptom, and the database CPU may still look idle because the connections are blocked on locks or IO, not burning cycles. Always set a statement timeout so a runaway query is killed rather than holding a connection hostage:
SET statement_timeout = '3s';
3. Pool too small for the concurrency
Sometimes nothing is broken — there is simply more concurrent work than the pool can hold. By Little’s Law, the connections needed equal arrival rate times average hold time:
required_connections = throughput (req/s) × avg_query_time (s)
500 req/s × 0.020 s = 10 connections needed
500 req/s × 0.200 s = 100 connections needed ← 10x slower queries, 10x pool
If your pool is 10 but the workload needs 100, requests queue and time out even though everything is “working.” This is also why slow queries cause exhaustion: they inflate the second term.
4. Long-held transactions and external calls inside a transaction
The deadliest variant: holding a connection open across a slow operation that isn’t a query at all. The classic is making an HTTP call to a third-party API while holding a database transaction open:
with pool.acquire() as conn:
conn.execute("BEGIN")
conn.execute("UPDATE orders SET status='paid' WHERE id=%s", oid)
charge_credit_card(order) # network call — 2 seconds, sometimes 30
conn.execute("COMMIT")
The connection is pinned for the entire network round trip. A slow or hung downstream dependency now exhausts your database pool — your outage is caused by someone else’s outage, transmitted through held connections. Never hold a database connection across an external call. Do the work, release the connection, then make the call (or use the outbox pattern for the follow-up).
Diagnosing exhaustion fast
The tell is the combination: application-side connection-acquisition timeouts plus a healthy-looking database. Confirm it with two questions.
What are the database connections doing right now?
SELECT state, count(*), max(now() - state_change) AS longest
FROM pg_stat_activity
GROUP BY state;
A pile of idle in transaction is the smoking gun for cause #4 — transactions opened and abandoned. A pile of active with long durations points to slow queries (#2). Few connections from your app at all, despite timeouts, points to a leak shrinking the pool (#1).
What does the pool itself report? Every good pool exposes metrics: active count, idle count, pending (waiting) count, and total. The signature of exhaustion is active == max_size and pending > 0 climbing. Graph these four numbers; they tell you immediately whether you’re leaking, saturated, or slow.
| Metric pattern | Likely cause |
|---|---|
| active = max, pending climbing, queries fast | Pool too small for concurrency |
| active = max, queries slow, db CPU low, locks high | Slow queries / lock waits |
| active grows over hours, never recovers | Connection leak |
many idle in transaction on db | Transaction held across slow work |
Sizing the pool correctly
The counterintuitive truth: bigger pools are often slower. Once the pool exceeds the database’s ability to do useful concurrent work (roughly bounded by CPU cores and disk spindles), extra connections just add context-switching and lock contention. The well-known HikariCP guidance — derived from the observation that a small pool often outperforms a large one — is to start small and size deliberately:
pool_size ≈ ((core_count × 2) + effective_spindle_count)
Then validate against Little’s Law for your actual throughput and query times, and crucially, sum across all instances and keep it under max_connections with headroom for migrations, admin tools, and replicas.
If your instance count times a sane per-instance pool still overruns the database, you need a connection pooler in front of the database — PgBouncer for PostgreSQL in transaction-pooling mode multiplexes thousands of client connections onto a small set of real database connections. This decouples application concurrency from database connection count and is the standard answer for high-instance-count deployments.
graph TD
A["50 app instances"] --> B["PgBouncer (transaction pooling)"]
B --> C["20 real connections to Postgres"]
C --> D["Postgres max_connections = 100"]
Configuration that prevents the outage
A defensive pool configuration looks like this:
max_sizesized by Little’s Law and summed across instances, under the database limit.connectionTimeoutshort (a few seconds) so a starved request fails fast and sheds load instead of piling up.maxLifetimebelow the database’s and the network’s idle-connection timeouts, so the pool retires connections before the database or a load balancer silently kills them — otherwise you lease a dead connection and the first query fails.statement_timeouton the database side to cap any single query’s hold time.leakDetectionThresholdon so leaks surface in logs, not in 3 a.m. pages.idle in transactionsession timeout on the database to auto-kill abandoned transactions.
Summary
Connection pool exhaustion presents as generic latency and errors while the database looks healthy, because the failure happens in the application’s wait queue, not the database. The pool is bounded because connections are expensive and the database has a hard ceiling — and oversubscribing it across many instances is the most common root cause. Exhaustion comes from leaks, slow queries holding connections, undersized pools, and the cardinal sin of holding a connection across an external call. Diagnose it with pool metrics (active at max, pending climbing) and pg_stat_activity. Size pools small and deliberately via Little’s Law, sum across instances, reach for PgBouncer at high instance counts, and configure timeouts and leak detection so the pool fails fast and tells you why.