Skip to content
Node.js6 min read

Graceful Shutdown and Health Checks for Node Services

Dropped requests during deploys are self-inflicted. How to handle SIGTERM properly, drain in-flight work, and expose health checks orchestrators can trust.

Every deploy kills your old processes. The only question is whether they die politely — finishing the requests they had accepted, closing database connections, telling the load balancer to stop sending traffic — or mid-sentence, leaving clients with connection resets and half-committed work. Node gives you everything needed for the polite version; it just does none of it by default.

What SIGTERM should trigger

Orchestrators such as Kubernetes send SIGTERM, wait a grace period (30 seconds by default), then follow with an unstoppable SIGKILL. Your job is to fit an orderly exit inside that window: stop accepting new connections, let in-flight requests complete, close outbound resources, then exit cleanly.

js
let shuttingDown = false;

process.on('SIGTERM', async () => {
  shuttingDown = true;
  server.close(async () => {
    await queue.close();
    await db.end();
    process.exit(0);
  });
  // hard deadline inside the orchestrator's grace period
  setTimeout(() => process.exit(1), 25000).unref();
});

Three details carry the weight here. server.close stops new connections while allowing existing ones to finish. The hard deadline guarantees you never hang forever on a stuck connection — better to exit dirty at 25 seconds than be SIGKILLed blind at 30. And unref() on the timer means it will not itself keep the process alive once everything else has drained. Handle SIGINT the same way so local Ctrl-C behaves like production.

One wrinkle to know about: server.close waits for active requests, but idle keep-alive connections can hold the server open. Node 18.2 added server.closeIdleConnections for exactly this — call it straight after close — and closeAllConnections exists as the blunt instrument for the hard-deadline path. If TLS terminates at a proxy, drain there too, or the proxy keeps feeding sockets to a server that is trying to die.

Liveness and readiness are different questions

Health endpoints fail most often by conflating two distinct questions. Liveness asks: is this process beyond saving and in need of a restart? Readiness asks: should this process receive traffic right now? Restarting a healthy pod because its database dependency blipped — a liveness probe checking the database — is a classic way to turn a small outage into a big one.

  • Liveness: return 200 if the event loop is responsive. Nothing else. Restarts fix crashes, not dependency outages.
  • Readiness: return 503 while starting up, when a critical dependency is unreachable, and immediately once shutdown begins.
  • Keep both endpoints free of auth, logging noise and rate limits.
js
app.get('/healthz', (req, res) => res.send('ok'));

app.get('/readyz', async (req, res) => {
  if (shuttingDown) return res.status(503).send('draining');
  const dbOk = await pingDatabase();
  res.status(dbOk ? 200 : 503).end();
});

Flipping readiness to 503 the moment SIGTERM arrives is the piece most teams miss. It tells the load balancer to drain you before connections start failing, which is what makes zero-downtime deploys actually zero-downtime rather than merely brief-downtime.

The same choreography applies beyond HTTP. Queue workers should stop claiming new jobs on SIGTERM and finish the ones they hold; schedulers should skip their next tick; WebSocket servers should stop accepting upgrades and let clients reconnect to the new instances. Anything that pulls work needs its own equivalent of closing the front door.

Test it before production does

The good news is this is easy to verify: run the service locally, hold a slow request open with curl, send kill -TERM, and confirm the request completes while new ones are refused. Five minutes of testing here regularly saves a launch-day incident, because shutdown bugs only ever surface during deploys — precisely when you are watching something else.

Graceful shutdown is unglamorous, invisible when it works, and one of the clearest markers of a production-grade service. Wire it once per codebase and every deploy after that gets quieter.

Start your project

Have an idea? Let's ship it together.

Tell us what you're building — we'll reply within one business day with an honest take and a clear next step.