10LOC
Node.jsintermediate

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 piece close() 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 of closeIdleConnections() — 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() after server.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 as SIGTERM.