Every deploy and autoscaling event stops processes; done badly, users see errors every time.
Orchestrators stop a process by sending SIGTERM, waiting a grace period (30 s by default in Kubernetes), then sending SIGKILL. A graceful shutdown uses that window to finish in-flight work.
The sequence:
- Stop receiving traffic. Flip your readiness check to failing so the load balancer drains you. In Kubernetes, endpoint removal is asynchronous, so a short delay before closing the listener avoids refused connections.
- Stop accepting connections:
server.close(callback). It stops listening and (since Node 19) also closes idle keep-alive sockets, but the callback fires only when every connection has ended — requests in progress, slow uploads, SSE streams and WebSockets keep it waiting. - Drain connections. Let in-flight requests finish; tell long-lived streams to wind down. After a deadline,
server.closeAllConnections()forcibly closes whatever is left. - Finish background work — queues, timers, pending writes — and close resources in reverse order of creation: HTTP server, then job consumers, then database pools, then flush logs.
- Exit with a timer as a safety net:
setTimeout(() => process.exit(1), 10_000).unref().unref()means the timer itself doesn't keep the process alive.
Watch out for the process being PID 1 in a container: without a handler Node won't get default signal behaviour there, and npm start as the entrypoint may not forward signals at all. Run node directly (CMD ["node", "server.js"]) or use an init like tini.
Track open handles with ref/unref: anything that keeps the loop alive (intervals, sockets) prevents natural exit; process.getActiveResourcesInfo() lists them when shutdown hangs.
Make shutdown idempotent — SIGTERM and SIGINT may both arrive — and test it by sending signals under load.
Example
import { createServer } from "node:http";
import { setTimeout as sleep } from "node:timers/promises";
let shuttingDown = false;
const db = { close: async () => { await sleep(50); console.log("db pool closed"); } };
const server = createServer(async (req, res) => {
if (req.url === "/ready") return res.writeHead(shuttingDown ? 503 : 200).end();
await sleep(1000); // simulate slow work
res.end("done\n");
}).listen(3000);
async function shutdown(signal) {
if (shuttingDown) return; // idempotent
shuttingDown = true;
console.log(`${signal}: draining`);
setTimeout(() => process.exit(1), 10_000).unref(); // safety net
await sleep(2000); // let the load balancer see /ready = 503
const closed = new Promise((resolve) => server.close(resolve)); // also drops idle sockets
const force = setTimeout(() => server.closeAllConnections(), 5000);
await closed; // in-flight requests have finished
clearTimeout(force);
await db.close();
console.log("bye"); // loop empties; exit code 0
}
process.on("SIGTERM", shutdown);
process.on("SIGINT", shutdown);Remember
- SIGTERM → fail readiness → close server → drain connections → close resources → exit
server.closewaits for active connections; force stragglers withcloseAllConnections- Use an unref'd timer as a forced-exit safety net
- Run node directly (or under tini) so it receives signals in containers
Watch out for
- Using
npm startas the container command so SIGTERM never reaches Node. - Calling
process.exit()immediately on SIGTERM and cutting off in-flight requests.