"Every deploy kills your process. The question is whether it kills in-flight requests with it. Production-grade means the answer is no."
The Lifecycle of a Deploy
When you redeploy a Node service, here's what happens (under launchd, systemd, PM2, or any sane substrate):
- Substrate sends SIGTERM to your process.
- Your process has N seconds to clean up before SIGKILL hits.
- SIGKILL terminates the process abruptly. Any in-flight requests die mid-response.
If your process ignores SIGTERM, every deploy drops connections. "Zero-downtime deploy" requires SIGTERM handling: stop accepting new requests, finish the in-flight ones, then exit cleanly.
The Pattern
import http from 'node:http';
const server = http.createServer(handler);
server.listen(3000);
let shuttingDown = false;
for (const sig of ['SIGINT', 'SIGTERM']) {
process.on(sig, () => {
if (shuttingDown) return; // ignore second signal
shuttingDown = true;
console.log(`got ${sig}, draining...`);
// Stop accepting new connections; finish in-flight ones
server.close((err) => {
if (err) {
console.error('drain error:', err);
process.exit(1);
}
console.log('drained cleanly');
process.exit(0);
});
// Backstop — force exit if drain takes too long
setTimeout(() => {
console.warn('drain timed out, force-exiting');
process.exit(1);
}, 10_000).unref();
});
}
The five lines that matter: handle SIGTERM, set a flag (idempotent), call server.close(), exit when drained, force-exit on timeout. This is the entire pattern.
The Forgotten Cleanups
- Database connections — call
.end()or.close()on your DB pool. - Open file handles — close JSONL log writers, append-only files.
- WebSocket connections — send a close frame, let clients reconnect to a new instance.
- Pending jobs — flush a queue, mark in-flight jobs as 'must retry,' etc.
- External subscriptions — unsubscribe from Kafka/Redis/PubSub.
terminus that wrap the pattern.Healthcheck Coordination
Load balancers send healthcheck pings (GET /health). When draining starts, your healthcheck should start returning failure — that tells the LB to stop routing new requests to you while in-flight ones finish. Without this, the LB keeps sending new traffic during your drain window, defeating the point:
let healthy = true;
process.on('SIGTERM', () => { healthy = false; /* then drain */ });
app.get('/health', (_req, res) => {
res.writeHead(healthy ? 200 : 503).end();
});
The LB sees 503, removes you from the pool, drained requests finish, you exit. The 10-second drain window now contains zero new traffic — only the in-flight requests from before drain started.
Crash vs Graceful Stop
Graceful shutdown is for planned exits — deploys, scaling down, manual restart. For unplanned exits (uncaught exception, OOM, segfault), the process dies regardless. The substrate restarts it; the LB notices via healthcheck failure and re-routes. The pattern is:
- Planned exits → SIGTERM handler → drain → exit cleanly.
- Unplanned exits → crash, substrate restarts, LB re-routes.
Both paths matter. Some teams catch uncaughtException and try to drain; usually a bad idea because the process state is already corrupted. Better: log + exit fast + let the substrate restart.