Catch Connection Pool Leaks in Tests
Push concurrency past the pool size and assert every acquired client is returned, so a missing release fails in CI instead of hanging production.
Task
Write a test that fails when a request handler forgets to release a pooled database client on any code path.
When to use this
A handler releases its client on success but not inside the catch block. Under low traffic nothing shows, until concurrency exceeds the pool and every request waits forever for a connection.
Prerequisites
- Node.js 22 with the built-in node:test runner
- node-postgres (pg) and a disposable test database
- A handler that acquires clients with pool.connect()
Steps
1. Wrap the pool so the test can count acquisitions that were never released.
export function trackedPool(pool) {
let outstanding = 0;
const connect = pool.connect.bind(pool);
pool.connect = async (...args) => {
const client = await connect(...args);
outstanding += 1;
const release = client.release.bind(client);
client.release = (...releaseArgs) => {
outstanding -= 1;
return release(...releaseArgs);
};
return client;
};
return { pool, outstanding: () => outstanding };
}
Expected output: Every acquired client increments the counter once and decrements it exactly once on release.
2. Run more concurrent requests than the pool allows, with a short acquisition timeout.
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL, max: 2, connectionTimeoutMillis: 750 });
const tracked = trackedPool(pool);
test('orders handler returns every client', async () => {
await Promise.all(Array.from({ length: 6 }, () => request('/api/orders')));
assert.equal(tracked.outstanding(), 0);
assert.equal(pool.waitingCount, 0);
});
Expected output: The test passes with a correct handler and never needs more than two connections.
3. Prove the test can fail by removing a release in a temporary fixture handler.
test('leak detector is effective', async () => {
await assert.rejects(
runBrokenHandler(tracked.pool),
/timeout exceeded when trying to connect/,
);
});
Expected output: The deliberately broken handler fails after about 750 ms instead of hanging the suite.
4. Run the suite and confirm the assertion is part of the normal CI command.
node --test test/pool-leak.test.jsExpected output: ok 1 - orders handler returns every client; ok 2 - leak detector is effective
Troubleshooting
Problem
The test passes even though a handler leaks
Solution
The pool max is probably larger than the request count, or the handler uses pool.query() which releases automatically. Keep max at 1 or 2 and force real concurrency.
Problem
The suite hangs instead of failing
Solution
Set connectionTimeoutMillis and a test-level timeout so a leak surfaces as a bounded failure with a clear message.
Limitations
- The counter only observes clients acquired through the instrumented pool instance.
- It detects missing releases, not slow queries that hold a client for too long; pair it with pg_stat_activity monitoring.
Leaks hide until concurrency arrives
A pool has a fixed number of slots. pool.connect() checks out a client and requires a matching release(). If an early return or a thrown error skips the release, that slot stays checked out for the life of the process. In development, one leaked client is invisible because the pool has spare capacity. In production, the leak accumulates with every error path until the pool is empty and all requests block on acquisition. The failure looks like a database outage, but the database is idle.
Make the leak a test failure
The only reliable signal is the number of outstanding acquisitions after a burst of requests. Wrap connect and release, force concurrency above max, and assert the counter returns to zero. Use max: 2 and six parallel requests so a single leak is enough to exhaust the pool. Keep connectionTimeoutMillis short: a bounded rejection with a timeout message is a much better CI signal than a hung job.
test('handler returns every client', async () => {
await Promise.all(Array.from({ length: 6 }, () => request('/api/orders')));
assert.equal(tracked.outstanding(), 0);
});
Prefer a helper over discipline
Code review does not reliably catch a missing release in a rarely used catch block. Provide a withClient(fn) helper that uses try/finally, and ban direct pool.connect() outside that helper with a lint rule. Tests then only need to cover the helper once, and handlers cannot leak by construction.
Verify the detector itself
A leak test that can never fail is worse than none. Keep one temporary handler that intentionally skips release and assert the test times out at the acquisition boundary. If you change the pool configuration, this self-check confirms the guard still has teeth.
Watch the slow version too
Not every exhausted pool is a leak. Long-running queries and forgotten transactions hold clients legitimately. Add a monitoring query against pg_stat_activity that flags sessions idle in transaction for more than a minute, and include the query text in the alert so responders can tell a leak from a slow report.