Graceful shutdown: draining connections on SIGTERM
Published November 27, 2026
import { createServer } from "node:http";
const server = createServer((_req, res) => {
setTimeout(() => res.end("ok"), 50); // pretend this request takes a moment
});
server.listen(3000);
process.on("SIGTERM", () => {
server.close(() => process.exit(0)); // wait for in-flight requests, then exit
server.closeIdleConnections(); // don't wait on idle keep-alive sockets
setTimeout(() => process.exit(1), 10_000).unref(); // hard exit if draining hangs
});What
On SIGTERM — the signal orchestrators like Kubernetes, systemd, and Docker send before killing a process — this server stops accepting new connections, lets requests already in progress finish, closes idle keep-alive sockets immediately, and force-exits if draining takes longer than 10 seconds.
Why it matters
The default behavior of SIGTERM on a Node process is to terminate it — immediately, mid-request, whatever those requests were doing. In a rolling deploy or autoscaling event, that's a guaranteed batch of dropped responses every single time a container gets recycled, entirely avoidable.
server.close() looks like the whole fix but isn't: it stops the server from accepting new connections, then waits for existing ones to end on their own before its callback fires — but an idle HTTP keep-alive connection has no reason to end on its own, so close() alone can hang indefinitely with a client just sitting there holding a socket open. And even a correct drain needs a ceiling: something downstream can always hang, and a deploy that waits forever for one is worse than a deploy that force-exits after a bounded timeout.
How it works
server.close(() => process.exit(0))stops accepting new connections immediately; its callback only fires once every connection the server still knows about has closed.server.closeIdleConnections()(Node 18.2+) forces closed any keep-alive socket that isn't in the middle of a request right now — the piececlose()alone won't do for you.- The
setTimeout(..., 10_000).unref()is the safety net: if requests genuinely haven't finished in 10 seconds, exit anyway rather than hang the deploy..unref()keeps this timer from itself being a reason the process stays alive if everything else finished cleanly first. - The handler's own
setTimeout(() => res.end("ok"), 50)stands in for "a request that's still doing real work" — proof the server actually waits for it instead of cutting it off.
Gotchas
closeAllConnections()is the more aggressive sibling ofcloseIdleConnections()— it kills active requests too. Don't reach for it in a graceful path; that's what the hard-exit timeout is for.- Order matters: call
closeIdleConnections()afterserver.close(), not before, to avoid a race where a connection goes idle in between the two calls and gets missed. - This handles HTTP connections. If the process also holds a database pool, a message queue consumer, or other long-lived resources, they need their own explicit shutdown in the same handler —
server.close()knows nothing about them.
Related
AbortController— for cancelling in-flight outbound requests (e.g. to a database or another service) as part of the same shutdown sequence.process.on("SIGINT")— the Ctrl+C signal during local development; often wired to the same handler asSIGTERM.