Graceful Shutdown
When an orchestrator like Kubernetes, a process manager like PM2, or a plain docker stop decides to replace your Express process, it sends a signal and gives you a short grace period before forcibly killing it. A graceful shutdown uses that window to stop accepting new connections, let in-flight requests finish, and close database pools and other resources. Skipping this step means dropped requests, half-written database rows, and leaked connections during every deploy — exactly the kind of papercut that erodes a “zero downtime” rollout.
Which signals to listen for
Process managers signal intent to stop with POSIX signals. The two that matter for web servers are SIGTERM (the polite “please stop” that Kubernetes and docker stop send) and SIGINT (what you get from Ctrl+C in a terminal). You should treat both the same way.
| Signal | Sent by | Default action | Catchable |
|---|---|---|---|
SIGTERM | Kubernetes, docker stop, PM2 | Terminate | Yes |
SIGINT | Ctrl+C in terminal | Terminate | Yes |
SIGKILL | kill -9, OOM killer | Force kill | No — cannot be handled |
SIGKILL(andSIGSTOP) can never be intercepted. The whole point of graceful shutdown is to finish your work before the orchestrator escalates fromSIGTERMtoSIGKILL, which Kubernetes does afterterminationGracePeriodSeconds(30s by default).
Draining requests with server.close
The core primitive is server.close(). It stops the server from accepting new connections immediately, but keeps existing connections open until their in-flight requests complete. The callback fires once every active connection has finished — that is your signal that it’s safe to close downstream resources and exit.
Capture the value returned by app.listen(); that is the http.Server instance you call close() on.
import express from 'express';
const app = express();
app.get('/health', (req, res) => res.json({ status: 'ok' }));
app.get('/work', async (req, res) => {
// Simulate a slow downstream call
await new Promise((resolve) => setTimeout(resolve, 3000));
res.json({ done: true });
});
const server = app.listen(3000, () => {
console.log('Listening on http://localhost:3000');
});
Closing the database and other resources
Once server.close() has drained HTTP traffic, tear down anything that holds open sockets or file handles: database pools, Redis clients, message-queue consumers. Do this after the drain so in-flight handlers can still query the database while they finish.
import express from 'express';
import { Pool } from 'pg';
const app = express();
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
app.get('/users/:id', async (req, res, next) => {
try {
const { rows } = await pool.query('SELECT * FROM users WHERE id = $1', [req.params.id]);
res.json(rows[0] ?? null);
} catch (err) {
next(err);
}
});
const server = app.listen(3000, () => console.log('Listening on :3000'));
let shuttingDown = false;
async function shutdown(signal) {
if (shuttingDown) return; // ignore repeated signals
shuttingDown = true;
console.log(`${signal} received: starting graceful shutdown`);
// 1. Stop accepting new connections, drain existing ones.
server.close(async (err) => {
if (err) {
console.error('Error during server close', err);
process.exit(1);
}
// 2. Close downstream resources.
await pool.end();
console.log('Drained requests and closed DB pool. Bye.');
process.exit(0);
});
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
Output:
Listening on :3000
SIGTERM received: starting graceful shutdown
Drained requests and closed DB pool. Bye.
Forcing a timeout for stuck connections
server.close() waits forever if a client keeps a connection open (think a hung keep-alive socket or a slow streaming response). To guarantee the process exits within the grace period, arm a timer that force-exits if draining takes too long. Call .unref() on the timer so it never keeps the event loop alive on its own.
const SHUTDOWN_TIMEOUT_MS = 10_000;
async function shutdown(signal) {
if (shuttingDown) return;
shuttingDown = true;
console.log(`${signal} received: draining (max ${SHUTDOWN_TIMEOUT_MS}ms)`);
const forceExit = setTimeout(() => {
console.error('Drain timed out — forcing exit');
process.exit(1);
}, SHUTDOWN_TIMEOUT_MS).unref();
server.close(async () => {
clearTimeout(forceExit);
await pool.end();
process.exit(0);
});
}
In Node 18.2+ you can also close idle keep-alive sockets immediately with server.closeIdleConnections(), and forcibly destroy all sockets with server.closeAllConnections() — useful if you want a hard cutoff after a shorter window.
Always keep your shutdown timeout shorter than the orchestrator’s grace period. If Kubernetes uses the default
terminationGracePeriodSeconds: 30, a 10s app timeout leaves headroom for the platform to clean up before it escalates toSIGKILL.
Express 5 and a readiness note
Express 5 runs on the same underlying http.Server, so the server.close() pattern is identical; the only behavioral change is that async route handlers can reject and Express 5 forwards the error to your error middleware automatically. For zero-downtime rollouts, also flip your /health (readiness) endpoint to return 503 as soon as shutdown begins, so the load balancer stops routing new traffic to the pod while existing requests drain.
app.get('/health', (req, res) => {
res.status(shuttingDown ? 503 : 200).json({ status: shuttingDown ? 'shutting_down' : 'ok' });
});
Best Practices
- Listen for both
SIGTERMandSIGINT, and guard against repeated signals with ashuttingDownflag. - Drain HTTP first with
server.close(), then close DB pools, Redis, and queue consumers in the callback. - Always arm an
.unref()’d timeout so a stuck socket can’t block exit past the orchestrator’s grace period. - Keep the app shutdown timeout shorter than
terminationGracePeriodSeconds(or your platform’s equivalent). - Flip readiness probes to
503on shutdown so traffic stops arriving before connections are forced closed. - Exit with code
0on a clean drain and a non-zero code on timeout or error, so supervisors can distinguish them. - Use
server.closeIdleConnections()to release idle keep-alive sockets quickly without dropping active requests.