Reliability

Database pool saturation: find the queue that grew first

A connection can remain occupied while the application waits elsewhere. Follow the checkout interval, test competing explanations and verify a narrowly scoped fix.

Modeled workload
12 simultaneous requests, 40 ms SQL and an 800 ms remote lookup
Diagnostic distinction
Pool wait, connection hold and SQL duration are different intervals
Evidence scope
Executed scheduling model with no database benchmark or recorded incident
Four blue connection channels occupied by amber waiting blocks, with a queue of small request tokens in front.
Conceptual illustration of occupied connection slots and waiting requests.

The connection stays busy after SQL stops

A database connection pool can be exhausted while individual SQL statements remain fast. The missing interval is often connection hold time: everything between acquisition and release, including application work or a remote call that runs while the connection is checked out. Start the investigation there when pool wait rises without a matching increase in query duration.

The reconstruction below models twelve simultaneous requests, four connections, 40 ms of SQL work and an 800 ms external lookup. Holding the connection across that lookup gives the last group a 1,680 ms pool wait. Moving an independent lookup before acquisition reduces the modeled maximum wait to 80 ms. These are calculated timings under fixed assumptions, not a recorded outage or a database performance claim.

That diagnosis is conditional. A slow query, lock contention, leaked connection or arrival burst can produce a similar alert. The useful evidence is the order in which the intervals grow.

Reconstruct one request before increasing capacity

Record four timestamps around the application pool: request arrival, borrow request, acquisition and release. Add SQL spans and external-call spans to the same trace. Use a monotonic clock for durations within a process. Across services, preserve trace relationships and account for clock differences before subtracting wall-clock timestamps.

For the first modeled request, the connection is acquired at 0 ms. SQL runs until 20 ms, an external lookup runs until 820 ms, and the second SQL operation finishes at 840 ms. The pool considers this connection occupied for all 840 ms. Only 40 ms belongs to SQL execution. At 0 ms, the first four requests occupy the four slots and eight other requests wait for a borrow.

The first request holds a connection for 840 milliseconds: 20 milliseconds SQL, 800 milliseconds external work and 20 milliseconds SQL.
Figure 1. First-request timeline in the deterministic reconstruction. Checkout duration includes the external lookup. View full-size figure.

A SQL-duration dashboard cannot reconstruct the checkout interval by itself. Capture connection release in cleanup paths as well as successful returns. A client timeout proves that the caller stopped waiting. It does not prove that the application returned the connection. Correlate abandoned requests with outstanding checkouts before declaring a leak.

For PostgreSQL, pg_stat_activity supplies server-side state and wait information. An idle-in-transaction backend has a transaction open without executing a query. An active backend can also be waiting on an event. Neither state directly measures time spent waiting for an application pool slot. Join database observations to the application trace instead of treating them as interchangeable metrics.

Separate the competing explanations

Use each observation to choose a discriminating probe. High pool utilization alone cannot tell you which row in this table applies. Collect the connection count and wait distribution per application instance as well as across the fleet: an aggregate can conceal one overloaded process.

Discriminating probes for a saturated application connection pool
Suspected causeEvidence to seekProbe that could reject it
External work holds a connectionLong checkout span overlapping the remote callCompare checkout and SQL intervals on the same request
Slow SQL or lock contentionSQL span grows and server wait evidence matchesInspect the specific query and blocker during the interval
Connection is never returnedOutstanding checkout survives request completionExercise success, exception and cancellation cleanup paths
Arrival burst exceeds capacityBorrow queue grows while hold duration stays stableCompare arrival rate and concurrency with the same route at normal load
One tenant monopolizes capacityWaiters correlate with a tenant's concurrent workloadBreak down admitted work and hold time by tenant

The final row connects this diagnosis to SaaS noisy-neighbor isolation. Increasing a shared pool may postpone a queue while preserving the same tenant imbalance. Admission control and workload isolation address a different boundary from connection lifetime.

Replay the checkout schedule

Download the standard-library Python replay, save it as replay.py in an empty working directory, and run it with Python 3. It writes a per-request CSV trace and JSON results. A heap represents the next free time of each connection. No database, network, sleep or random generator is involved.

All twelve requests arrive at time zero. Each connection handles one request at a time. In the first two scenarios, a request holds its connection for 840 ms. In the third, all requests perform an independent 800 ms lookup before asking for a connection, then hold it for 40 ms. This assumes the SQL operations can move together after the lookup without changing the business invariant.

Deterministic schedules with fixed durations and no database contention
Calculated scenarioPool sizeHold per requestMaximum pool waitLast completion from arrival
Remote lookup inside checkout4840 ms1,680 ms2,520 ms
Same workflow, larger pool8840 ms840 ms1,680 ms
Independent lookup before checkout440 ms80 ms920 ms
Calculated maximum pool waits are 1680, 840 and 80 milliseconds. Last completions are 2520, 1680 and 920 milliseconds for held remote work, a larger pool and remote work before checkout.
Figure 2. Two different clocks: pool wait begins at borrow request. Completion begins at request arrival. Values come from the saved replay. View full-size figure.

Pool wait starts when a borrow is requested. In the third scenario that is 800 ms after arrival, so the 80 ms maximum is not total request latency. The model assumes unconstrained external-call concurrency and no database contention. A real dependency limit, uneven response times, query contention or continuous arrivals would change the schedule. Raising the pool size is not guaranteed to reproduce the middle row on a real database.

Shorten ownership without breaking the transaction

Move remote work outside the checkout only after identifying what the transaction protects. If the remote request depends on a locked balance, moving it earlier can invalidate the decision. One possible redesign reads a versioned snapshot, releases the connection, performs the lookup, then reacquires a connection and checks the version before committing. A conflict needs an explicit retry or rejection path.

That redesign also has a boundary: an external side effect may already have happened when the final database check fails. Use a durable intent and an appropriate reconciliation workflow when atomic local state cannot cover the remote effect. The transactional outbox crash cases explain a relevant handoff pattern. An outbox does not make two systems share one transaction.

Bound both admission and borrowing by the remaining request deadline. Give timed-out work a cleanup path. Do not simply raise every instance's pool limit: PostgreSQL's connection settings describe a server-wide connection ceiling and resource allocation tied to it. Budget the combined application fleet, background workers and operational access.

Verify the first queue shrinks

Replay the same arrival pattern against the changed application in an isolated test environment. Compare borrow wait, checkout duration, SQL duration, completed work and error outcomes. Test dependency slowness and cancellation, not just the successful path. Confirm that a shorter checkout has not introduced stale decisions, duplicate external effects or failed cleanup.

The fix is supported when the predicted interval shrinks and the business invariant still holds. If pool wait stays high after remote work leaves the checkout, return to the trace: a different queue or a database constraint is now setting the limit.

Sources

Documentation checked .

  1. PostgreSQL: monitoring activity
  2. PostgreSQL: connection settings