Graceful shutdown and production lifecycle

Node.js · Expert · 18 min

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.close waits for active connections; force stragglers with closeAllConnections
  • 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 start as the container command so SIGTERM never reaches Node.
  • Calling process.exit() immediately on SIGTERM and cutting off in-flight requests.

Check yourself

  1. During shutdown, server.close()'s callback hasn't fired after 30 seconds. Most likely reason?
  2. Why call .unref() on the forced-exit timer?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: nodejs.org/docs/latest-v22.x/api/http.html#serverclosecallback