Graceful Shutdown and Process Signals in Node.js

Graceful shutdown for Node.js and Express: SIGTERM handling, keep-alive sockets, closing the pool last, readiness checks and Docker signal fixes.

Executive Summary: A deploy should never cut off a request. Here I add a graceful shutdown to an Express app on Node 10: a SIGTERM handler, a force-exit timer, manual tracking of keep-alive sockets, the right order for closing the pool, a failing readiness check, and fixes for npm and Docker swallowing the signal.
Graceful shutdown watercolor: a server rack at dusk under a power symbol, with the last request envelopes landing in a tray

Every deploy stops the old Node.js process and starts a new one. If the old process dies mid-request, users get errors on every release. Here I write about how I add a graceful shutdown to a Node.js and Express app: catching SIGTERM, finishing requests in flight, closing idle keep-alive connections and releasing the PostgreSQL pool last.

The code runs on Node 10.13 LTS and Express 4.16. The examples use a plain Express app with a node-postgres pool, so the shutdown also has a database to close. All output below comes from real runs.

Signals: how the platform asks you to stop

When Docker, Kubernetes, systemd or PM2 wants a process to stop, it first sends SIGTERM. Pressing Ctrl+C in a terminal sends SIGINT. If the process is still alive after a grace period, the platform sends SIGKILL, which cannot be caught. Docker waits 10 seconds by default, and Kubernetes waits 30.

By default, Node exits at once on SIGTERM. Open requests are cut off, and the client sees a reset connection. A graceful shutdown replaces that default with a handler, as described in the Node.js signal events docs.

Step 1: The basic graceful shutdown handler

The core is server.close. It stops accepting new connections and calls back once all existing ones have closed:

let shuttingDown = false;

function shutdown(signal) {
  if (shuttingDown) return;
  shuttingDown = true;
  console.log(`${signal} received, closing server`);

  const started = Date.now();
  server.close(() => {
    console.log(`server closed after ${Date.now() - started} ms`);
    process.exit(0);
  });

  setTimeout(() => {
    console.error('forcing exit');
    process.exit(1);
  }, 10000).unref();
}

process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));

The timer is the safety net of every graceful shutdown. If something hangs, the process still exits before the platform kills it. Also, unref() means the timer itself never keeps the process alive.

To test it, I started a two-second request and sent SIGTERM during it:

SIGTERM received, closing server
{"ok":true}server closed after 1714 ms

The client got its full response, and then the process exited with code 0.

Step 2: The keep-alive problem

That first test hides a trap. Browsers, load balancers and HTTP agents keep connections open between requests. Since Node 8, an idle keep-alive socket stays open for 5 seconds, the default server.keepAliveTimeout. And server.close waits for it.

I repeated the test with a client that keeps its socket open:

client got 200 keeping socket open
SIGTERM received, closing server
server closed after 4552 ms

Nearly five seconds for a server with no work. Worse, a client that keeps sending requests on that socket can hold it open for good. Then the force timer fires, and the graceful shutdown is not graceful at all.

Step 3: Track sockets and close the idle ones

Node 10 has no built-in way to close idle connections, so I track them myself:

const sockets = new Set();

server.on('connection', (socket) => {
  socket.idle = true;
  sockets.add(socket);
  socket.on('close', () => sockets.delete(socket));
});

server.on('request', (req, res) => {
  const socket = req.socket;
  socket.idle = false;
  if (shuttingDown) res.setHeader('Connection', 'close');
  res.on('finish', () => {
    socket.idle = true;
    if (shuttingDown) socket.end();
  });
});

Then, inside shutdown, right after server.close, I end every idle socket:

for (const socket of sockets) {
  if (socket.idle) socket.end();
}

Busy sockets finish their response first. After that, the finish handler closes them, and the Connection: close header tells the client not to reuse them. Running both tests together, with an idle keep-alive client and a slow request:

client got 200
SIGTERM received, closing server
{"ok":true}server closed after 1510 ms

Now the process exits as soon as the slow request is done. That is a real graceful shutdown.

Step 4: Close resources in the right order

A graceful shutdown closes things in reverse order of startup. First, stop taking traffic. Next, finish requests. Only then close the PostgreSQL connection pool, because requests in flight still need it:

server.close(async () => {
  try {
    await pool.end();
    console.log('pool closed');
    process.exit(0);
  } catch (err) {
    console.error('error during shutdown', err);
    process.exit(1);
  }
});

The same applies to Redis clients, message queue consumers and timers. If a consumer pulls jobs from a queue, stop it before the pool closes. Otherwise, a job may start and fail halfway. Also, flush buffered logs before exit, or the last lines of a failed shutdown are lost.

Step 5: Fail the readiness check during graceful shutdown

During a graceful shutdown, a load balancer may keep sending traffic for a few seconds after SIGTERM. So the health route reports the state:

app.get('/healthz', (req, res) => {
  res.status(shuttingDown ? 503 : 200).json({ shuttingDown });
});

On Kubernetes, I point the readiness probe at this route, never the liveness probe. In addition, a preStop hook such as sleep 5 gives the endpoints time to update before the signal arrives. Keep terminationGracePeriodSeconds longer than your force timer plus that sleep. The Kubernetes pod termination docs explain the order of events.

Docker and npm can swallow the signal

In my test, npm start received SIGTERM, but the Node app under it did not. One second later, the app was still running. In a container, the same thing happens when the start command is npm start or a shell script.

There is a second problem. The process with PID 1 in a container gets no default signal handling from the kernel. So a Node app without its own handler ignores SIGTERM as PID 1 and waits for SIGKILL. My fixes:

  • Start Node directly: CMD ["node", "src/server.js"], not npm start.
  • Run the container with docker run --init, or add tini as the entrypoint. Either one forwards signals and reaps zombie processes.
  • Use the exec form of CMD, with brackets, so no shell sits in between.

Graceful shutdown with cluster and PM2

When the app runs under the cluster module with one worker per CPU core, each worker needs the same handler. Also, the master must not restart workers that exit during shutdown. PM2 sends SIGINT on reload and waits 1600 ms by default before killing. Raise kill_timeout in the PM2 config to match your force timer.

Common graceful shutdown mistakes

  • Calling process.exit right away in the handler. That is the same as no handler.
  • Forgetting keep-alive sockets. The server then waits up to five seconds or forever.
  • Closing the pool first. Requests in flight then fail with pool errors.
  • No force timer. One stuck request blocks the deploy until SIGKILL.
  • Starting with npm start in Docker. The signal may never reach Node.

FAQ

What is the difference between SIGTERM and SIGKILL?

SIGTERM is a polite request to stop, and your code can catch it. SIGKILL ends the process at once, and nothing in Node can catch or delay it.

Why does server.close not exit my process?

It waits for every open connection, including idle keep-alive ones. Track sockets and end the idle ones, as shown above.

How long should a graceful shutdown take?

Usually a few seconds. Set the force timer just above your slowest normal request, and keep it below the platform grace period.

Should I handle uncaughtException the same way?

Log it and exit, but do not wait long. After an uncaught exception, the process state is unknown. My guide to Node.js error handling middleware covers that case.

A clean stop is part of a clean deploy, and it takes less than fifty lines. Catch SIGTERM, close idle sockets and let the work in flight finish. Then close the pool last, and users never notice a release.

Last updated on 31 October 2026

DevOps Express Graceful Shutdown Node.js SIGTERM

Share this article

Leave a Reply

Your email address will not be published. Required fields are marked *