Shutdown Check

Search documentation

Find a page or section

Graceful shutdown in Node.js

Stop new traffic, finish active requests, close resources, and exit before the deadline.

A Node.js graceful shutdown begins when the process receives SIGTERM. The service stops taking new work, lets active requests finish, closes shared resources, and exits with code 0 before the platform deadline.

What happens without a SIGTERM handler?

On macOS and Linux, Node.js uses the default signal behavior and terminates. Open requests can lose their connections, and a proxy may turn that into a 502 or another upstream error.

shutdown-check sees the interrupted request as SC201:

FAIL SC201: In-flight request was interrupted: aborted

Adding a signal listener removes the default immediate exit. From that point, your code is responsible for completing shutdown and allowing the process to exit.

How do I handle SIGTERM in Node.js?

A reliable handler follows this order:

  1. Mark the instance as not ready.
  2. Reject new work that still arrives.
  3. Stop accepting new connections.
  4. Wait for active HTTP requests.
  5. Close databases, queues, workers, and timers.
  6. Set a failure exit code only if cleanup fails.
  7. Let Node.js exit when no handles remain.

The exact order of steps 3–5 depends on the application. Do not close a database pool while active handlers still need it.

node:http example

server.js
const http = require("node:http");
 
const port = Number(process.env.PORT ?? 3000);
let draining = false;
 
const server = http.createServer((request, response) => {
  if (request.url === "/health") {
    response.writeHead(draining ? 503 : 200, {
      "content-type": "text/plain",
    });
    response.end(draining ? "draining" : "ok");
    return;
  }
 
  if (draining) {
    response.writeHead(503, {
      connection: "close",
      "retry-after": "1",
    });
    response.end("shutting down");
    return;
  }
 
  if (request.url === "/slow") {
    response.writeHead(200, { "content-type": "text/plain" });
    response.flushHeaders();
    setTimeout(() => response.end("work complete\n"), 2000);
    return;
  }
 
  response.writeHead(404).end();
});
 
server.listen(port, "127.0.0.1");
 
process.on("SIGTERM", () => {
  if (draining) return;
  draining = true;
 
  server.close((error) => {
    if (error) {
      console.error("HTTP shutdown failed", error);
      process.exitCode = 1;
    }
  });
});

The draining guard makes repeated SIGTERM events harmless. Requests that entered before the flag changed continue in their existing handlers. New requests receive 503 until the listener closes.

server.close() stops new connections and waits for active requests. Its callback runs after the server closes, but other resources may still keep the event loop alive.

Express graceful shutdown

Express uses a Node.js HTTP server underneath. Keep the object returned by app.listen() and close it from the signal handler.

server.js
const express = require("express");
 
const app = express();
const port = Number(process.env.PORT ?? 3000);
let draining = false;
 
app.get("/health", (_request, response) => {
  response.status(draining ? 503 : 200).send(draining ? "draining" : "ok");
});
 
app.use((_request, response, next) => {
  if (!draining) return next();
  response.set("connection", "close");
  response.set("retry-after", "1");
  response.status(503).send("shutting down");
});
 
app.get("/slow", async (_request, response) => {
  response.status(200);
  response.flushHeaders();
  await new Promise((resolve) => setTimeout(resolve, 2000));
  response.end("work complete\n");
});
 
const server = app.listen(port, "127.0.0.1");
 
process.on("SIGTERM", () => {
  if (draining) return;
  draining = true;
 
  server.close((error) => {
    if (error) {
      console.error(error);
      process.exitCode = 1;
    }
  });
});

Place the draining middleware before application routes that should reject new work, but after the readiness route so it can return a clear readiness status.

Fastify graceful shutdown

Fastify provides fastify.close(), which stops accepting requests and runs registered onClose hooks.

server.js
const Fastify = require("fastify");
 
const fastify = Fastify();
const port = Number(process.env.PORT ?? 3000);
let draining = false;
 
fastify.get("/health", async (_request, reply) => {
  reply.code(draining ? 503 : 200);
  return draining ? "draining" : "ok";
});
 
fastify.get("/slow", async (_request, reply) => {
  reply.raw.writeHead(200, { "content-type": "text/plain" });
  reply.raw.flushHeaders();
  await new Promise((resolve) => setTimeout(resolve, 2000));
  reply.raw.end("work complete\n");
});
 
await fastify.listen({ port, host: "127.0.0.1" });
 
process.on("SIGTERM", async () => {
  if (draining) return;
  draining = true;
 
  try {
    await fastify.close();
  } catch (error) {
    fastify.log.error(error);
    process.exitCode = 1;
  }
});

Register database and queue cleanup with Fastify hooks or await it after HTTP work has drained.

Close application resources

An HTTP server can close while Node.js remains alive. After active requests no longer need them, close:

  • database and cache pools;
  • message consumers and producers;
  • workers and child processes;
  • scheduled intervals;
  • file watchers;
  • telemetry exporters;
  • long-lived clients.

Use timeouts for cleanup that depends on another system. Without a timeout, the service may remain alive until the platform sends SIGKILL, reported by shutdown-check as SC300.

Handle keep-alive connections

server.close() stops new connections and waits for active requests. Modern Node.js versions also close idle keep-alive connections. If your stack tracks sockets itself, do not destroy every socket on SIGTERM; active sockets carry the requests you are trying to preserve.

If you explicitly close idle connections, do it after the listener begins closing and keep active connections untouched.

Add a hard safety timeout carefully

Production services often need a final timeout so a broken cleanup step does not run forever. The timeout must be longer than the normal drain and shorter than the platform's forced-kill deadline.

const forceExit = setTimeout(() => {
  console.error("shutdown deadline exceeded");
  process.exit(1);
}, 25_000);
 
forceExit.unref();

An unref'ed timer does not keep an otherwise finished process alive. A forced exit can still cut off work, so treat it as a last resort and monitor when it runs.

Common mistakes

MistakeWhat users seeshutdown-check result
No signal handlerRequests reset during deploymentSC201 or SC301
process.exit() in the handlerActive work is cut offSC201
Database closes before requests finishHandler error or wrong responseSC202 or SC203
Server closes but another handle stays openProcess reaches the platform deadlineSC300
Wrapper does not forward signalsParent exits while child server survivesSC302
Readiness stays at 200New traffic continues to arriveSC310
New work is still acceptedLate requests may be cut offSC311
Second signal forces exitExisting requests are interruptedSC114 or SC201

Verify the handler

Use a config that checks the full behavior:

shutdown-check.json
{
  "command": ["node", "server.js"],
  "baseUrl": "http://127.0.0.1:3000",
  "readiness": { "path": "/health" },
  "workload": {
    "path": "/slow",
    "bodyIncludes": "work complete",
    "started": { "type": "response-headers" }
  },
  "shutdown": {
    "deadlineMs": 10000,
    "readinessWithdrawal": true,
    "newRequests": { "path": "/slow", "rejectStatuses": [503] },
    "repeatSignalAfterMs": 500
  }
}

Run:

npx shutdown-check test

A passing timeline should show active work before the signal, readiness and new-request rejection after it, request completion, and a clean process exit.