# Graceful shutdown in Node.js

> Node.js graceful shutdown: handle SIGTERM with server.close() in node:http, Express and Fastify, avoid the common mistakes, and verify it with shutdown-check.

Source: https://shutdown.jscrate.dev/docs/guides/graceful-shutdown-nodejs
Last updated: 2026-09-23

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](https://shutdown.jscrate.dev/docs/codes/sc201):

```text
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

```js title="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.

```js title="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.

```js title="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](https://shutdown.jscrate.dev/docs/codes/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.

```js
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:

```json title="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:

```bash
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.

## Related

- [Quick start](https://shutdown.jscrate.dev/docs/quick-start)
- [Readiness and draining](https://shutdown.jscrate.dev/docs/guides/readiness-and-draining)
- [In-flight work and barriers](https://shutdown.jscrate.dev/docs/in-flight-work)
- [Kubernetes and containers](https://shutdown.jscrate.dev/docs/guides/kubernetes)
- [Troubleshooting](https://shutdown.jscrate.dev/docs/troubleshooting)
