TL;DR: Low database CPU can accompany storage, network or lock waits as well as connections held idle: leaked without return, parked in idle-in-transaction, or queued behind locks. Diagnose who holds them and for how long before you touch pool sizes.
How to approach it
State the reasoning explicitly: low CPU rules out neither database saturation nor a storage bottleneck. Inspect wait events and application-side checkout duration alongside CPU. Ask what the pool metrics say (checked out, pending waiters) and whether the app recently deployed anything touching transactions.
A strong answer
Pool exhaustion with an idle database has three common holders, and each leaves a different fingerprint on the database side.
Idle in transaction. A request opened a transaction, did some work, and never committed or rolled back: an exception path that fails to close the database transaction, an ORM session left open across a slow external call, a debug breakpoint in staging that someone replicated with a sleep in prod code. On Postgres, one query answers it:
SELECT pid, state, now() - xact_start AS xact_age, left(query, 80)
FROM pg_stat_activity
WHERE state = 'idle in transaction'
ORDER BY xact_age DESC;
Long-lived rows are suspects; confirm whether their age is legitimate. A role-scoped idle-in-transaction timeout can bound future damage after checking workload requirements. If using ALTER SYSTEM SET idle_in_transaction_session_timeout = '60s', reload configuration with SELECT pg_reload_conf() and verify the effective setting; review the broader impact before applying it. The real fix is returning the connection in a finally block or letting the framework's transaction scope own it.
Lock waits. A migration took an ACCESS EXCLUSIVE lock, or a batch job holds row locks while it commits slowly, and every other connection queues behind it without consuming CPU. pg_stat_activity joined against pg_locks shows the head of the chain and everyone waiting on it. Canceling or terminating a confirmed blocker can release waiters, but first check transaction ownership, rollback cost and application consequences. A blocker is evidence to investigate, not proof that a particular deploy caused it.
A genuine leak. Connections checked out on a code path that throws before release. The signature is exhaustion that worsens over hours until restart, then repeats. Pool dashboards (HikariCP waiting threads, pgbouncer cl_waiting) confirm demand exceeds supply while the server reports mostly idle sessions.
Only after all three are excluded does sizing deserve attention, and the arithmetic matters more than folklore: benchmark the workload's throughput and latency as active concurrency rises; there is no universal active-connections-per-core limit. Storage waits and query shape can dominate CPU. Sum the pools of every application replica, because forty pods each with a pool of twenty promise 800 connections to a server configured for 200, and the queue belongs at a pooler like PgBouncer in transaction mode. Transaction pooling historically broke protocol-level prepared statements (recent PgBouncer handles them, verify your version) and still breaks session state such as advisory locks and SET variables, which is the follow-up hiding behind this question.
Follow one checked-out connection back to its owner
Suppose a pool has 20 connections checked out and 80 callers waiting. The database reports 18 sessions idle and two executing short queries. That does not establish that the idle sessions are available to the application: an application request can hold a connection while waiting for an HTTP dependency or performing CPU work.
Correlate checkout duration with request traces and transaction scope. If the slow external call occurs inside the transaction, shorten that scope where correctness permits and ensure exception paths release resources. If database wait events instead show blocked queries, identify the blocker and evaluate the transaction's business effect before terminating it.
For a replica increase from ten pods to twenty, a pool maximum of 20 doubles the potential database connections from 200 to 400. Include rollout surge and background jobs in that budget. Validate the chosen limit with acquisition wait and database throughput; unused CPU alone does not justify adding more concurrent transactions.
What interviewers probe next
"Why is scaling the database wrong here?" A larger instance does not fix a leaked checkout or change a configured transaction timeout. Scaling can help a measured storage or compute bottleneck, so distinguish that case before dismissing it.
"What would you add to catch this before customers do?" Alert on pool pending waiters and on oldest idle-in-transaction age, both cheap and both fire long before checkout fails.
"Where do retries fit?" Nowhere yet. Retrying into an exhausted pool amplifies the queue. Fix the holder first; retry budgets come back once the pool is healthy.
Common mistakes
Restarting the application as remediation and closing the ticket. It drains the pool and destroys the evidence, and because leaks recur on a schedule you have bought hours.
Raising max_connections on the database. Each connection is a process with real memory, and thousands of mostly-idle processes make the next real incident slower to diagnose.
Assuming the pool library's default maximum is sensible. Verify defaults against the application and database concurrency budget.
References