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: abortedAdding 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:
- Mark the instance as not ready.
- Reject new work that still arrives.
- Stop accepting new connections.
- Wait for active HTTP requests.
- Close databases, queues, workers, and timers.
- Set a failure exit code only if cleanup fails.
- 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
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.
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.
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
| Mistake | What users see | shutdown-check result |
|---|---|---|
| No signal handler | Requests reset during deployment | SC201 or SC301 |
process.exit() in the handler | Active work is cut off | SC201 |
| Database closes before requests finish | Handler error or wrong response | SC202 or SC203 |
| Server closes but another handle stays open | Process reaches the platform deadline | SC300 |
| Wrapper does not forward signals | Parent exits while child server survives | SC302 |
Readiness stays at 200 | New traffic continues to arrive | SC310 |
| New work is still accepted | Late requests may be cut off | SC311 |
| Second signal forces exit | Existing requests are interrupted | SC114 or SC201 |
Verify the handler
Use a config that checks the full behavior:
{
"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 testA passing timeline should show active work before the signal, readiness and new-request rejection after it, request completion, and a clean process exit.