Skip to main content
KBY Technologies Logo
root/systems-engineering/diagnosing-pgbouncer-pool-exhaustion-under-load

Diagnosing PgBouncer Pool Exhaustion Under Load

Why 40 stateless API pods can saturate PgBouncer while Postgres sits idle, and how transaction-mode pooling and pool sizing math fix it.
Diagnosing PgBouncer Pool Exhaustion Under Load
16/07/2026|10 min read|

At 04:12 UTC a fleet of forty stateless API pods, each holding a modest max_connections=20 pool, drives a single PgBouncer instance into saturation. Latency on SELECT queries climbs from 3ms to 4,000ms. No query is slow. No index is missing. The database itself is idle. This is connection pool exhaustion — the point at which every backend slot in the pooler’s server-side pool is checked out and new client requests queue indefinitely waiting for a free connection. It is one of the most common production incidents in Postgres-backed systems, and it is almost never caused by the database engine itself.

The failure is architectural, not query-level. PgBouncer sits between potentially thousands of client connections and a fixed, small number of physical Postgres backends. When the ratio of concurrent client demand to default_pool_size inverts — which happens the moment a downstream dependency (an external API, a slow lock, a runaway transaction) holds connections longer than expected — the queue depth grows unbounded and every subsequent request pays the tax, regardless of how trivial that individual query is.

#Architectural Breakdown: Pooling Modes and Where They Break

PgBouncer supports three pooling modes, and the choice dictates exactly how connection pool exhaustion manifests under load.

#Session Pooling

A client connection is bound to a server connection for the lifetime of the client’s TCP session. This is the safest mode for compatibility (prepared statements, SET commands, advisory locks all work correctly) but it scales worst. A pool of 100 server connections supports at most 100 concurrent clients — full stop. Any application using a connection-per-request web framework without its own client-side pooling will exhaust this almost immediately under moderate concurrency.

#Transaction Pooling

The server connection is returned to the pool the instant a transaction commits or rolls back, not when the client disconnects. This is the mode most production deployments run because it multiplexes hundreds of client connections onto a handful of server connections. The trade-off: anything that relies on session-scoped state — SET search_path, LISTEN/NOTIFY, prepared statement caching via PREPARE, temp tables — becomes unreliable because the next statement in the same client session may land on a completely different backend.

#Statement Pooling

The most aggressive mode, releasing the connection after every individual statement. It breaks multi-statement transactions outright and is rarely used outside read-only analytics proxies.

Transaction mode is where connection pool exhaustion becomes a subtle, recurring incident rather than an obvious hard cap. Because the pool looks elastic, teams under-provision default_pool_size assuming Postgres’s own max_connections (commonly 100–200) is the real ceiling. It isn’t — the practical ceiling is however many transactions can be held open simultaneously by upstream client load, and that number is a function of application concurrency, not database capacity.

connection pool exhaustion

#Implementation Logic: Sizing the Pool Correctly

The correct sizing exercise starts from Postgres’s own backend limit, not from an arbitrary round number. Little’s Law gives a workable first approximation: required_connections = arrival_rate * average_transaction_duration. If your service issues 2,000 transactions/second and each holds a server connection for an average of 8ms, you need roughly 16 concurrent backend connections at steady state — but you must size for the tail, not the mean, because a single slow query (a missing index, a lock wait) inflates average_transaction_duration disproportionately.

Step-by-step remediation for a production incident:

  • Capture live pool state with SHOW POOLS; and SHOW CLIENTS; via the PgBouncer admin console to confirm whether cl_waiting is non-zero (queued clients) versus sv_active saturating at pool_size.
  • Cross-reference with Postgres’s own pg_stat_activity to identify long-held transactions consuming server slots disproportionately.
  • Separate pools by workload class — reporting, background jobs, and low-latency API traffic should never share a pool, because one noisy tenant will starve the others.
  • Apply server_idle_timeout and query_wait_timeout aggressively so stuck clients fail fast rather than holding the queue.

#Reference Configuration

1[databases]
2api_primary = host=pg-primary.internal port=5432 dbname=core pool_size=40
3reporting = host=pg-replica.internal port=5432 dbname=core pool_size=10
4
5[pgbouncer]
6pool_mode = transaction
7max_client_conn = 4000
8default_pool_size = 40
9reserve_pool_size = 8
10reserve_pool_timeout = 3
11query_wait_timeout = 10
12server_idle_timeout = 60
13server_lifetime = 1800
14ignore_startup_parameters = extra_float_digits
15stats_period = 30
16admin_users = pgbadmin

reserve_pool_size is the mechanism that most engineers overlook. It permits PgBouncer to briefly exceed default_pool_size when the wait queue backs up past reserve_pool_timeout seconds, giving a controlled release valve instead of a hard wall — provided Postgres’s own max_connections has headroom to absorb the overflow.

#Diagnosing a Live Exhaustion Event

The admin console is the fastest diagnostic surface. Connect directly to the pooler’s virtual admin database:

1psql -h 127.0.0.1 -p 6432 -U pgbadmin pgbouncer -c "SHOW POOLS;"

The critical columns are cl_active, cl_waiting, sv_active, and sv_idle. A pool where cl_waiting is climbing while sv_active is pinned exactly at pool_size is the textbook signature of connection pool exhaustion — clients are queued because every server slot is occupied, and the pool cannot grow past its configured ceiling.

On the Postgres side, the following query surfaces the transactions actually holding those slots hostage:

1SELECT pid, usename, state, wait_event_type, now() - xact_start AS xact_age, query
2FROM pg_stat_activity
3WHERE state != 'idle'
4ORDER BY xact_age DESC
5LIMIT 20;

In the majority of incidents this query reveals one of two culprits: an application holding a transaction open across a network call to a third-party service (a classic anti-pattern in ORMs that lazily fetch related rows inside the same transaction as an HTTP request), or a lock wait cascading from an unrelated DDL migration.

connection pool exhaustion

Rendering diagram...

#Failure Modes and Edge Cases

Transaction pooling introduces failure classes that session pooling never exposes. Prepared statements created via PREPARE or driver-level statement caching (common in JDBC and npm’s pg with statement caching enabled) will occasionally execute against a backend that never saw the PREPARE call, producing intermittent prepared statement does not exist errors under load — these are pooling artefacts, not application bugs, and disabling client-side prepared statement caching is usually the correct fix rather than chasing a phantom race condition.

A second edge case is idle-in-transaction leakage. If application code opens a transaction, performs a read, then awaits an external call before committing, that server connection is unavailable to every other client for the duration of the external call. Under load this single anti-pattern is the dominant cause of connection pool exhaustion in microservice architectures, far more often than raw traffic volume. Auditing for this requires correlating pg_stat_activity.state = 'idle in transaction' against application trace spans, not just database metrics.

A third failure mode surfaces during failover events. When a primary is promoted and PgBouncer reconnects, every server connection is torn down simultaneously. If server_login_retry is set too aggressively low, PgBouncer will mark the backend as down prematurely, and the entire pool collapses to zero available connections precisely when demand for reconnection is highest — a self-inflicted exhaustion event layered on top of the original failover.

Pooling ModeMultiplexing RatioSession State SupportTypical Exhaustion Trigger
Session1:1FullClient concurrency exceeds pool_size directly
TransactionMany:1None (session vars unsafe)Long-held transactions, idle-in-transaction leaks
StatementVery highNone (no multi-statement txns)Rare; used only for stateless read replicas

#Scaling and Security Trade-offs

Solving connection pool exhaustion by simply raising default_pool_size and max_connections is a false economy. Every additional Postgres backend consumes shared memory for its process context and adds contention on internal locks (particularly ProcArrayLock during commit). The correct lever is usually reducing transaction hold time and workload isolation, not raw ceiling increases. When designing the surrounding database architecture patterns for a multi-tenant platform, treat the pooler tier as its own capacity-planned component, not an afterthought bolted onto the connection string.

  • Vertical pool scaling (raising max_connections) improves throughput ceiling but degrades tail latency past roughly 300–500 active backends on typical mid-tier hardware due to lock contention — horizontal pooler sharding scales further without this penalty.
  • PgBouncer clustering behind a TCP load balancer removes the single-instance bottleneck but reintroduces the session-affinity problem for anything relying on prepared statements or advisory locks scoped per connection.
  • reserve_pool_size trades a small burst-capacity buffer for reduced predictability — useful for absorbing traffic spikes, dangerous if left uncapped against a Postgres instance with no connection headroom of its own.
  • TLS termination at the pooler (via client_tls_sslmode) protects credentials in transit between application and pooler but adds per-connection handshake cost that becomes material at multiplexing ratios above roughly 20:1, favouring mutual TLS with session resumption over per-request negotiation.
  • Admin console exposure via SHOW POOLS is invaluable for diagnostics but must be restricted to a dedicated admin_users role with no query privileges on production data — the console itself is a viable lateral-movement target if left on a shared network segment.

Connection pool exhaustion is rarely a database capacity problem in disguise — it is a symptom of transaction lifetime mismatched against pooler configuration, and the fix is almost always found in application transaction boundaries and pool topology rather than in Postgres tuning parameters. Instrument cl_waiting as a first-class alerting signal, treat idle-in-transaction duration as a hard SLO, and the pooler stops being the fragile component it currently is in most production stacks.

Reader Interaction

Comments

Add a thoughtful note on Diagnosing PgBouncer Pool Exhaustion Under Load. Comments are checked for spam and held for moderation before appearing.

Loading comments...

Loading anti-spam check...

Alistair Vance

Written By

Alistair Vance

Alistair Vance brings over fifteen years of experience architecting resilient, multi-region Kubernetes clusters for tier-one financial institutions. A core contributor to several CNCF incubation projects, his expertise lies in constructing automated self-healing infrastructures and establishing stringent service level objectives. He focuses relentlessly on operational excellence and eliminating toil through advanced eBPF-based observability.