Coding

Node.js graceful shutdown: stop dropping requests on every deploy

Why rolling deploys drop requests, and the drain order and code that stops it in Express and Fastify.

Node.js graceful shutdown, done right. Cover graphic for a guide to draining requests on SIGTERM in Express and Fastify.

Every deploy, a handful of customers get a 502 or a connection reset, and by the time anyone looks, the graphs are clean again. The cause is usually not the new code: it is the old process being killed with requests still in flight. Node.js graceful shutdown fixes it with a small amount of code: on SIGTERM, fail readiness, keep serving for a few seconds, call server.close(), wait for in-flight requests up to a deadline, then close the database pool and exit.

Below is why deploys drop requests, the exact order of operations, and working shutdown code for Express and Fastify that you can add in an afternoon.

Why do deploys drop requests in Node.js?

Every platform (Kubernetes, ECS, Fly.io, Render, Railway, Heroku) does roughly the same thing when it replaces your process:

  1. Sends SIGTERM.
  2. Waits a grace period. Kubernetes defaults to 30 seconds; docker stop to 10. PaaS platforms set their own, so check yours.
  3. Sends SIGKILL, which cannot be caught.

The catch is that removing your instance from the load balancer happens at the same time as the SIGTERM, not before. In Kubernetes, the pod leaves the Service endpoints while the signal is delivered, and proxies take a few seconds to notice. New requests can still arrive after your process decided to stop.

That leads to four common failures:

  • No signal handler. Node exits immediately on SIGTERM and cuts every in-flight request. As PID 1 in a container it is worse: the signal is ignored, the grace period runs out, and you get SIGKILL plus a slow deploy.
  • Closing the listener too early. Requests routed to you during the load balancer’s delay get connection errors.
  • Keep-alive reuse. A client sends a new request on an open keep-alive connection after shutdown starts.
  • Closing the database first. The pool closes before requests finish, and you get a burst of 500s.

Node.js graceful shutdown: the order that works

1. Fail readiness

Your readiness endpoint returns 503 so health checks stop routing traffic to you.

2. Keep serving for 5 to 10 seconds

Let the load balancer catch up. Requests that still arrive are served normally.

3. Stop accepting connections

Call server.close(). Since Node 19 it also closes idle keep-alive connections, then waits for active requests before calling back.

4. Enforce a deadline

If requests are still running at the deadline, server.closeAllConnections() (Node 18.2+) force-closes them. The deadline must be shorter than the platform’s grace period, so you exit with logs flushed instead of being killed mid-write.

5. Close dependencies, then exit

Stop queue consumers and intervals, close the database pool and Redis client, exit 0.

Express graceful shutdown example

js
import express from 'express';
import { setTimeout as sleep } from 'node:timers/promises';
import { pool } from './db.js';

const app = express();
let shuttingDown = false;

app.get('/ready', (req, res) => {
  res.status(shuttingDown ? 503 : 200).send(shuttingDown ? 'draining' : 'ok');
});

// While draining, ask keep-alive clients to reconnect elsewhere.
app.use((req, res, next) => {
  if (shuttingDown) res.set('Connection', 'close');
  next();
});

app.get('/orders/:id', async (req, res) => {
  const { rows } = await pool.query('select * from orders where id = $1', [req.params.id]);
  res.json(rows[0] ?? null);
});

const server = app.listen(process.env.PORT ?? 3000);

const DRAIN_DELAY_MS = 5_000;
const HARD_DEADLINE_MS = 20_000; // under a 30s grace period

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

  setTimeout(() => {
    console.error('shutdown deadline hit, forcing close');
    server.closeAllConnections();
    process.exit(1);
  }, HARD_DEADLINE_MS).unref();

  await sleep(DRAIN_DELAY_MS);
  await new Promise((resolve) => server.close(resolve)); // waits for in-flight requests
  await pool.end();
  process.exit(0);
}

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

server.close() calls back only after active requests finish, so the pool closes only when nothing is using it. If you tag logs with request IDs, log the count of open requests at each step; it makes the first real rollout much easier to read.

Fastify graceful shutdown example

Fastify does much of this for you. fastify.close() stops the server, runs onClose hooks, and answers requests that arrive after close() with a 503 (return503OnClosing defaults to true). forceCloseConnections defaults to "idle" where supported, so idle keep-alive connections close for you. Because late requests get a 503, keep the drain delay before calling close():

js
import Fastify from 'fastify';
import { setTimeout as sleep } from 'node:timers/promises';
import { pool } from './db.js';

const app = Fastify({ logger: true });
let draining = false;

app.get('/ready', async (req, reply) => (draining ? reply.code(503).send('draining') : 'ok'));
app.addHook('onClose', async () => { await pool.end(); });

await app.listen({ port: Number(process.env.PORT ?? 3000), host: '0.0.0.0' });

for (const signal of ['SIGTERM', 'SIGINT']) {
  process.once(signal, async () => {
    draining = true;
    setTimeout(() => process.exit(1), 20_000).unref();
    await sleep(5_000);
    await app.close();
    process.exit(0);
  });
}

Containers, PID 1 and background work

  • Run node directly. Use the exec form CMD ["node", "server.js"]. A shell-form CMD or a shell-wrapped start script may not forward SIGTERM.
  • Use an init. docker run --init or tini as the entrypoint delivers signals properly to a process running as PID 1 (see the Docker and Node.js best practices).
  • Make the numbers fit. Drain delay + longest normal request < hard deadline < grace period. With the values above: 5 + 15 ≤ 20 < 30.
  • Fail readiness, not liveness. Failing liveness gets the container restarted mid-drain.
  • Background work. Stop pulling queue messages, finish and acknowledge the current one, clear intervals, flush batch writers before exit.

A solo developer on a PaaS can stop at the Express or Fastify code above. A 10-person team on Kubernetes should also agree on one grace period across services and put the shutdown function in a shared package so every service drains the same way.

How to test graceful shutdown

bash
node server.js & PID=$!
sleep 1
npx autocannon -c 50 -d 15 http://localhost:3000/orders/1 &
sleep 5 && kill -TERM $PID
wait

With one local process, requests arriving after close() fail because there is nowhere else to go. What matters is that requests in flight when the signal arrived all succeed. For the real test, run the same load through your load balancer during a rollout and expect zero errors. If you just moved to Node.js 24 LTS, this is a good test to add while you are in there.

If you only do one thing

  • Add a SIGTERM handler that waits 5 seconds, calls server.close(), then closes the pool.
  • Add a hard deadline shorter than your platform’s grace period.
  • Use exec-form CMD ["node", ...] and an init process in containers.
  • Watch 5xx and p95/p99 latency for the five minutes after your next deploy.

FAQ

What does server.close() do in Node.js?

It stops the server accepting new connections and, since Node 19, closes idle keep-alive connections. Its callback runs once active requests have finished, so it waits instead of cutting them off.

How long should a graceful shutdown take?

Long enough for the load balancer to stop routing (5 to 10 seconds) plus your slowest normal request, and always under the platform’s grace period. For most APIs, 15 to 25 seconds total is plenty.

Why does my Node app ignore SIGTERM in Docker?

As PID 1 without a handler, Node does not get the default signal behavior, so SIGTERM does nothing until SIGKILL arrives. Add a handler and run with --init or tini.

Doing this with ApiTraffic

Dropped requests during deploys last seconds and are easy to miss. If you would rather not build a dashboard to check every rollout, ApiTraffic logs every request and response from one middleware.

  • Record a deploy event on each rollout, then search status_code >= 500 or status_code == 503 around it. A working shutdown makes that window look like any other.
  • Regressions compares p95 before and after the deploy, which also catches a new instance that is slow while warming up.

Start free and see every request your API handles: apitraffic.io

Sources: Node.js HTTP docs (server.close, closeAllConnections) ; Fastify Server reference ; Kubernetes Pod Lifecycle, termination of Pods ; Docker and Node.js best practices

Keep reading