Implementing graceful connection pool shutdown in Express

This guide is part of Express.js Connection Pool Middleware. Unhandled process termination in Express leaves database connections in IDLE IN TRANSACTION or CLOSE_WAIT states. This triggers pool exhaustion and ECONNRESET errors during rolling updates. This guide provides a deterministic shutdown sequence to halt routing, drain active queries, and safely release pool resources. Proper signal interception becomes the primary control for zero-downtime deployments.

Key operational objectives:

  • Intercept SIGTERM/SIGINT signals before process exit
  • Stop the Express HTTP server to reject new TCP handshakes
  • Await pool.end() to flush in-flight queries and transactions
  • Validate connection teardown via OS-level sockets and DB-side metrics

Diagnosing the Shutdown Failure Mode

Improper pool teardown manifests immediately during deployment pipelines. Monitor your orchestration layer for ECONNRESET spikes coinciding with pod termination events. Lingering sockets prevent the database from reclaiming allocated memory.

Run ss -tnp | grep <port> on the host to identify CLOSE_WAIT states. Cross-reference your database max_connections utilization against deployment timestamps. Verify that liveness and readiness probes are not failing due to stalled query execution.

Connection leaks during rolling updates are typically caused by missing process signal handlers. Tracking middleware-bound clients before termination depends on the request-scoped acquisition pattern, and mid-drain backend drops should be handled per Handling node-postgres Pool Errors and Reconnection so the drain does not hang on a dead socket.

Implementing the Graceful Shutdown Sequence

A deterministic drain requires strict ordering. First, invoke server.close() to stop accepting new HTTP requests. Existing sockets complete their current response cycle.

Set a hard timeout (typically 30s) to force exit if the drain stalls. This prevents zombie processes from blocking orchestrator health checks.

Call pool.end() only after the HTTP server fully closes. This guarantees no new queries are dispatched during teardown. Attach a pool.on('error') listener before shutdown to catch mid-drain network drops or abrupt database restarts.

Shutdown ordering: correct versus severed The correct order stops the HTTP server, waits for in-flight requests, then ends the pool. Ending the pool first severs queries that are still running and leaves the client with a connection error. correct order server.close() stop accepting new requests await in-flight requests connections returned normally pool.end() idle sockets closed cleanly exit 0 severed order — pool ended first pool.end() while queries are running in-flight requests fail Connection terminated unexpectedly server still accepting new requests which now fail immediately — every one of them The drain deadline must fit inside terminationGracePeriodSeconds Otherwise SIGKILL arrives mid-drain and the outcome is identical to having no drain at all
Ending the pool before closing the server produces the worst possible ordering: existing work is severed while new work is still being accepted.

Exact Remediation: Pool Drain Implementation

The following implementation enforces async/await boundaries. The pg library’s pool.end() method waits for all checked-out clients to be released, then destroys all connections. No new connections can be acquired after pool.end() is called — there is no need to manually zero out pool.max. Drain duration is logged for SLO tracking and alert tuning.

const http = require('http');
const { Pool } = require('pg');

const pool = new Pool({ max: 20 });
const app = require('./app');
const server = http.createServer(app);

let isShuttingDown = false;

// Reject new requests during drain
app.use((req, res, next) => {
  if (isShuttingDown) {
    res.set('Connection', 'close');
    return res.status(503).json({ error: 'Server shutting down' });
  }
  next();
});

async function gracefulShutdown(signal) {
  console.log(`${signal} received. Draining connections...`);
  isShuttingDown = true;

  // 1. Stop accepting new TCP connections
  server.close(async () => {
    console.log('HTTP server closed.');

    try {
      // 2. Wait for all checked-out clients to be released, then destroy the pool
      await pool.end();
      console.log('Database pool drained successfully.');
      process.exit(0);
    } catch (err) {
      console.error('Pool drain failed:', err);
      process.exit(1);
    }
  });

  // 3. Hard timeout to prevent deployment pipeline stalls
  setTimeout(() => {
    console.error('Shutdown timeout exceeded. Forcing exit.');
    process.exit(1);
  }, 30000);
}

process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
process.on('SIGINT', () => gracefulShutdown('SIGINT'));

server.listen(3000);

pool.end() resolves once every client has called client.release() and all underlying connections are destroyed. If any client is never released (a leak), the drain will hang until the hard timeout fires — making leak detection a prerequisite for clean shutdowns.

Keep-Alive Connections Are Why server.close() Hangs

The most common report after implementing a drain is that shutdown takes exactly as long as the timeout, every time, on a service with no long-running requests. The cause is HTTP keep-alive: server.close() stops accepting new connections but waits for existing ones to become idle, and a keep-alive connection with no request in flight is not idle from Node’s perspective — it is an open socket the server is still willing to serve.

Node 18.2 added server.closeIdleConnections() and server.closeAllConnections() for exactly this. The correct sequence calls close(), then immediately closes idle connections, and only after the drain deadline forces the remainder shut. Without the middle call, a service behind a load balancer that maintains keep-alive connections will always hit its timeout.

async function shutdown(signal) {
  logger.info({ signal }, 'shutdown starting');

  server.close();                    // stop accepting new connections
  server.closeIdleConnections();     // release keep-alive sockets with no request in flight

  const deadline = setTimeout(() => {
    logger.warn('drain deadline reached, forcing remaining connections closed');
    server.closeAllConnections();    // sever whatever is left
  }, 20_000);

  await once(server, 'close');       // all connections finished or were forced
  clearTimeout(deadline);

  await pool.end();                  // only now: idle sockets closed, checked-out ones awaited
  logger.info('shutdown complete');
  process.exit(0);
}

for (const sig of ['SIGTERM', 'SIGINT']) process.on(sig, () => shutdown(sig));

Three details make this behave under an orchestrator. The 20-second deadline must be shorter than terminationGracePeriodSeconds, or the process is killed before pool.end() runs and the drain achieves nothing. The signal handler must be registered for SIGTERM specifically, since that is what Kubernetes sends. And readiness should start failing before this sequence begins — most load balancers take several seconds to stop routing, so a service that closes its listener the instant the signal arrives will still receive requests it can no longer serve.

The last point is worth a small deliberate delay. Failing the readiness probe, waiting five seconds for the load balancer to notice, and only then beginning the drain eliminates the handful of connection-refused errors that otherwise appear on every deploy.

Validation Commands & Post-Teardown Verification

Execute these commands immediately after deployment to confirm zero dangling connections. Validate socket closure at the OS layer before querying database metadata.

# Linux socket verification — check for CLOSE_WAIT on PostgreSQL port 5432
ss -tnp | grep 5432 | awk '{print $1, $6}' | sort | uniq -c

# PostgreSQL active connection check
psql -U admin -d mydb -c "SELECT state, count(*) FROM pg_stat_activity WHERE datname = 'mydb' GROUP BY state;"

# MySQL active connection check
mysql -u root -p -e "SHOW PROCESSLIST;"
Metric Safe Threshold Alert Condition Action
CLOSE_WAIT sockets 0 > 5 Force restart DB proxy, audit signal handlers
pg_stat_activity idle count 0 > pool.min Check for unhandled pool.end() rejections
ECONNRESET rate (APM) 0 > 0.1% Reduce timeout, verify load balancer drain window
pool.totalCount post-shutdown 0 > 0 Verify server.close() executes before pool.end()
Termination timeline inside the grace period After SIGTERM the service fails readiness, waits for the load balancer to stop routing, drains in-flight requests, ends the pool and exits, all inside the orchestrator's termination grace period. terminationGracePeriodSeconds = 30 s readiness fails LB stops routing — 5 s drain in-flight requests — up to 20 s pool.end() exit t = 0, SIGTERM t = 28 s, ~2 s of headroom why the 5 s readiness delay matters load balancers take seconds to notice; closing the listener immediately produces connection-refused errors what happens without headroom SIGKILL arrives before pool.end() — backends are left for the database's own timeout to reap
Every stage has to fit inside the grace period, with the readiness delay at the front and enough headroom at the back for the pool to close before SIGKILL.

Common Mistakes

Issue Operational Impact Remediation
Not setting a hard shutdown timeout Process hangs indefinitely if a connection is never released, stalling rolling deploys Add setTimeout(() => process.exit(1), 30000) immediately after calling server.close()
Terminating process before pool.end() resolves Leaves connections in CLOSE_WAIT state. Exhausts max_connections on subsequent deploys and triggers ECONNREFUSED. Wrap pool.end() in an await block inside the server.close() callback.
Not closing HTTP server before draining pool New requests acquire connections during shutdown. Prevents pool from reaching zero active queries, causing an infinite drain loop. Call server.close() first. Only proceed to pool.end() inside the server.close() callback.
Backend count across a rolling restart Without a drain, terminated pods leave backends open until the database reaps them, so each deploy leaves a step in the connection count. With a drain the count returns to baseline immediately. 0 backends deploy 1 deploy 2 deploy 3 no drain — ratchets up per deploy with drain — returns to baseline Deploy frequency, not traffic, is what exhausts the budget in the un-drained case.
The observable that proves a drain is working is the backend count across deploys: it should return to baseline within seconds rather than stepping upward each time.

FAQ

How long should I set the shutdown timeout before forcing process.exit()?
Set 30-45 seconds to align with typical load balancer drain timeouts. Force exit if pool.end() hangs to prevent zombie processes and deployment pipeline stalls.
Can I reuse the pool instance after calling pool.end()?
No. pool.end() permanently closes all underlying client connections. Re-instantiate the pool if the process continues running in a long-lived worker scenario.
How do I handle transactions that are still open during shutdown?
The pool’s end() method waits for active clients to be released, but it does not cancel in-progress queries. For long-running transactions, enforce a strict application-level drain window (e.g., 10s) and ensure all route handlers are wrapped in try/finally so connections return to the pool before shutdown completes.
Does process.exit() need to be called explicitly at the end?
Only if something else is keeping the event loop alive — a timer, an open handle, a metrics exporter. A clean process exits on its own once the loop empties, and calling exit() unconditionally can truncate a final log flush. Call it after a short delay, or not at all.
Do WebSocket connections need separate handling?
They do. A WebSocket is a long-lived connection that server.close() will wait for indefinitely, so a service with open sockets never finishes its drain. Close them explicitly with a close frame before waiting, giving clients a chance to reconnect to a healthy instance.
Should the drain wait for background jobs as well as HTTP requests?
If they run in the same process, yes — they hold connections from the same pool, and ending the pool underneath them severs their work exactly as it would a request. Track in-flight jobs explicitly and include them in the drain condition.
What happens to a connection checked out when pool.end() is called?
node-postgres waits for it to be released before resolving, which is why the ordering matters: the drain has to complete first, or pool.end() blocks until the request finishes anyway and the deadline is spent twice over.