Express.js and Database Pool Drivers: Connection Leakage during Client Disconnects
0 reputation · 04 Apr 2023, 13:13 UTC
0 reputation · 04 Apr 2023, 13:13 UTC
Express.js delegates connection pooling to external database drivers or ORMs. A critical challenge arises when a client terminates a request prematurely, while the server-side asynchronous route handler is still awaiting a database response or processing a transaction.
Because Express 4.x does not provide a native lifecycle hook to signal the cancellation of pending asynchronous operations upon request termination, there is a risk that connections remain active in the driver's pool until a driver-level timeout occurs. This behavior can lead to intermittent pool exhaustion under high load, as the application cannot proactively release handles for abandoned requests.
next() callback.How can a standardized mechanism be implemented to ensure database connections are released immediately when the client closes the connection? Is there a recommended pattern to synchronize the Express request lifecycle with the pool's connection release logic?
32775 reputation · 04 Apr 2023, 19:42 UTC
The short answer: Express 4.x will never cancel your async handler for you, so you have to wire the socket's close event to your pool release logic yourself. The standard pattern is explicit checkout (pool.connect()), a res.on('close') listener that cancels the in-flight query, and a finally block that guarantees client.release() runs exactly once. Driver-level timeouts then act as a backstop, not the primary cleanup mechanism.
Confirmed: Express 4.x has no request-cancellation hook. When the client aborts, the route handler keeps running, and any checked-out pool client stays held until the query finishes or a driver timeout fires. The Node HTTP layer does emit close on the request/response when the socket dies, which is the signal you can build on.
Likely explanation for your intermittent exhaustion: if handlers use per-query checkout (pool.query()) with no acquire timeout, each abandoned request holds a client for the full query duration. Under load, abandoned requests accumulate faster than idleTimeoutMillis reclaims them. This is a hypothesis worth verifying against pg_stat_activity before assuming disconnects are the sole cause — slow queries produce the same symptom.
const { Pool } = require('pg');
const pool = new Pool({ connectionTimeoutMillis: 5000 });
app.get('/report', async (req, res, next) => {
const client = await pool.connect();
let released = false;
const release = () => { if (!released) { released = true; client.release(); } };
const query = client.query('SELECT ...');
res.on('close', () => {
if (!res.writableFinished) {
// node-postgres: cancel the in-flight query, then release
client.connection?.query?.('SELECT pg_cancel_backend($1)', [client.processID])
.catch(() => {});
query.catch(() => {}); // swallow expected cancellation error
release();
}
});
try {
const result = await query;
res.json(result.rows);
} catch (err) {
next(err);
} finally {
release();
}
});
Key details:
pool.query() hides the client, so you cannot release it early. pool.connect() gives you the handle.close handler and the finally block can fire; guard so release() runs once.AbortSignal on query(); you cancel via pg_cancel_backend or by terminating the client. Releasing mid-query can surface errors in the still-running promise — attach a catch so it doesn't become an unhandled rejection.next() and can skip your cleanup. Use a wrapper (const wrap = fn => (req,res,next) => fn(req,res,next).catch(next)) or upgrade to Express 5, which forwards rejections natively. Verify which major version you run before choosing.Client-side cancellation alone can leave work running on PostgreSQL after the connection returns to the pool. Bound it at the database:
ALTER ROLE app_user SET statement_timeout = '15s';
ALTER ROLE app_user SET idle_in_transaction_session_timeout = '30s';
SELECT pg_sleep(30), call it with curl, and kill curl after one second. Watch pg_stat_activity and pool.totalCount/pool.idleCount — the client should return to idle promptly instead of after 30 seconds.res.on('close') handler and the finally block; confirm both fire and release executes exactly once per checkout.SHOW statement_timeout; and SHOW idle_in_transaction_session_timeout;.One assumption to check: this answer targets Express 4.x with node-postgres, as described. If you are on a different driver (mysql2, an ORM), the cancellation call differs — the checkout/close/finally structure stays the same.
Use comments to ask for clarification. Post a solution as an answer.
No question comments on this page.