Performance

Database connection pool exhaustion: what I check first

Before increasing the pool size, find out who is holding the connections and what everyone else is waiting for.

7 min read

The practical answer

Diagnose database connection pool exhaustion by separating time spent waiting for a connection from time spent using it. Inspect application pool metrics, database activity, and the combined connection limits across services. Then test the specific cause: a leak, long-held connection, slow database work, or excess concurrency.

A setting called ‘maximum connections’ makes the problem sound straightforward. Requests are waiting. The pool is full. Raise the number and let more work through. I understand the appeal, especially when customers are waiting too.

Before changing it, I want to understand what a connection is doing for the entire time the application owns it. The query might take a few milliseconds while the connection stays checked out for seconds. That gap interests me. Database connection pool exhaustion is a useful symptom because it forces us to look at how work moves through the system, including the parts our query dashboard never sees.

1. Locate the wait behind connection pool exhaustion

Start with one affected operation. Record when it asks for a connection, when it receives one, and when it returns it. Then compare those boundaries with the actual database calls. A timer around a repository function may include all of this, depending on where the instrumentation sits. Check before calling the whole duration ‘query time.’

In node-postgres, totalCount reports clients in the pool, idleCount reports clients available in the pool, and waitingCount reports queued acquisition requests. The library requires checked-out clients to be released when finished. A missed release can eventually leave later requests waiting or timing out.

Reference: node-postgres: Pool API and client release

Suppose a hypothetical trace shows a request waiting 800 milliseconds for a connection and then running 20 milliseconds of SQL. I would investigate the acquisition delay first. An index change may help other requests release their connections sooner, but those two measurements alone don’t establish that an index is the missing piece.

Compare affected instances with healthy ones during the same window. One full pool beside several quiet pools suggests a different investigation from every pool filling during the nightly import. Keep request errors and completed operations beside the waiting-time graph. A queue can shrink because callers gave up.

2. Count the connections your deployment can open

A pool limit belongs to a particular pool. The database sees the combined demand. The node-postgres sizing guide calls out service instances, autoscaling, and connection proxies because those details change how a local setting maps to database connections. Inventory the actual deployment before choosing a number.

Reference: node-postgres: Pool sizing across instances

Hypothetical connection inventory with no external pooler
WorkloadInstancesPool maximum eachPossible connections
API1020200
Import workers22040
Application total12Varies by workload240

If that hypothetical database has a 150-connection limit and the team sets aside 15 slots for reserved access and other clients, its planned application allowance is 135. The configured application maximum of 240 exceeds that allowance. These are possible simultaneous connections, not a claim that every pool eagerly opens its maximum.

PostgreSQL’s max_connections is a server connection ceiling. Reserved slots can restrict ordinary clients before the overall ceiling is reached, and raising the setting increases some resource allocations. Staying below the ceiling does not establish that the database can execute the workload efficiently.

Reference: PostgreSQL: Connection settings and reserved slots

Include overlapping old and new instances during a deploy. If a proxy or pooler sits in the middle, record its client and server limits separately. I’d rather discover an oversized budget in this small inventory than during the next scale-up.

3. Compare the pool with PostgreSQL activity

This read-only PostgreSQL query groups client connections to the current database. Use an authorized monitoring role; visibility into other sessions depends on permissions. Set useful application_name values in your clients so the groups are recognizable. Take observations over the affected period rather than treating one snapshot as the whole incident.

Reference: PostgreSQL: pg_stat_activity and session visibility

Connection activity for the current PostgreSQL database
SELECT
  application_name,
  state,
  wait_event_type,
  wait_event,
  count(*) AS connections
FROM pg_stat_activity
WHERE datname = current_database()
  AND backend_type = 'client backend'
  AND pid <> pg_backend_pid()
GROUP BY application_name, state, wait_event_type, wait_event
ORDER BY connections DESC;

PostgreSQL’s idle state means the server is waiting for another client command. Idle in transaction means a transaction remains open between commands. State and wait events describe different things: an active query can still be waiting on a lock or another resource. These fields help narrow the next check.

Reference: PostgreSQL: pg_stat_activity and session visibility

A server connection reported as idle may still be checked out by application code. That makes a full application pool alongside idle database sessions worth investigating. Follow one connection’s ownership through the code. Is the request waiting on an HTTP call, doing CPU work, or sitting in an error path before returning the connection?

The query deliberately leaves out SQL text. You can begin this investigation with counts and timing, then inspect specific statements through your normal restricted tooling when the evidence calls for it.

4. Explain why connections stay occupied

I would trace a few representative ownership paths before changing the pool. Include success, validation failure, exceptions, cancellation, and dependency timeouts. For manually acquired node-postgres clients, structure cleanup so release runs on those paths. A transaction also needs a defined commit or rollback outcome before its client returns to normal pool use.

Reference: node-postgres: Pool API and client release; node-postgres: Transactions

Imagine a hypothetical export that acquires a connection, reads a small record, calls an external service, and then performs another query. If the external call takes three seconds, the connection may spend most of its ownership time waiting on that service. Improving the first SQL query would barely change that interval.

Before moving the external call, check what consistency the workflow requires. Releasing a connection between two queries changes the design if those queries must share a transaction. Write down which values may change and what result the customer is entitled to. A shorter hold is useful only when the operation remains correct.

If the connection is occupied by database work, inspect that work: slow statements, blocking transactions, or a batch job creating more concurrency than expected. Different causes need different changes. A reliable cleanup path won’t fix an expensive query, and a faster query won’t repair a missed release.

5. Test one change against completed work

Choose the change your evidence supports. That might be releasing a client on an exception path, reducing worker concurrency, shortening a transaction, or testing a larger pool within a verified database budget. Keep the traffic shape comparable so you can tell whether the change helped.

For the hypothetical import workload, try a controlled concurrency change in staging and record how long the import takes alongside API acquisition waits. If the API improves but the import misses its required completion window, you have a trade-off to resolve. Moving a problem out of the customer-facing graph does not finish the work.

Connection-pool investigation note
Affected operation and time window:
Pool acquisition and holding-time distributions:
Pool totals, idle clients, and waiting requests:
Database activity and blocking evidence:
All client pools, scaling limits, and deploy overlap:
Leading explanation and proposed change:
Completed operations, errors, and queue age after change:
Rollout owner and reversal condition:

Watch the next representative traffic or batch cycle after rollout. I want to see requests finishing correctly, queues staying bounded, and the database handling its share of the work. Once we understand those relationships, the pool size becomes a decision we can explain.

References and further reading

All guides