Health Checks and Graceful Shutdown

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Express 5.3 (npm express 5.3.0, published 2026-10-09) on Node.js 18 or later, the minimum for Express 5, and it states where Express 4 (latest 4.22.3) behaves differently. Everything was checked against the official documentation and the package repositories on 2026-10-29. A service that is correct while it runs can still lose requests the moment it is started, restarted or replaced: a rolling deployment, an autoscaler removing a replica, a node being drained or a plain docker stop all end the same way, with a termination signal and a deadline. Handling that moment well is what turns a deployment into a non-event. The page covers the two halves of the problem, telling the platform whether the process is alive and whether it can take traffic (health checks), and stopping without dropping work (graceful shutdown), and builds both for the Bookshelf API that grows across this section. The examples use ESM and the node: prefix for built-in modules, and contain placeholders only, never real secrets. The Node.js side of the subject, signals, exit codes and the process object, is covered in Process, Signals and Exit Codes and Production Deployment; this page applies it to an Express application.

The two problems and one lifecycle

An orchestrator, a load balancer or a human needs answers to three questions about every instance:

  1. Has the process started and finished initialising? (startup)

  2. Is the process still working, or is it stuck and better restarted? (liveness)

  3. Can it serve requests right now, or should the load balancer skip it for a while? (readiness)

And at the end of an instance’s life one more:

  1. Can it stop without breaking the requests that are in flight? (graceful shutdown)

All four are facets of one lifecycle, and the figure below shows the states an Express process moves through and which signal or event moves it from one to the next. Readiness is the switch that a graceful shutdown flips first, which is why the two topics belong on one page.

stateDiagram-v2 [*] --> Starting Starting --> Ready: dependencies connected, server listening Ready --> NotReady: dependency check fails NotReady --> Ready: dependency check recovers Ready --> Draining: SIGTERM or SIGINT NotReady --> Draining: SIGTERM or SIGINT Draining --> Closing: readiness delay elapsed, server.close called Closing --> Stopped: in-flight requests finished, pools closed Closing --> Stopped: deadline reached, connections forced closed Stopped --> [*]

Express itself provides none of this. app.listen() returns the underlying node:http server, and health endpoints are ordinary routes. That is why most of the work is in the ordering of a few steps, and in the Node.js server methods that decide how connections end.

Liveness, readiness and startup

The three probe types answer different questions, and mixing them up is the most common cause of health-check outages.

Probe Question Failure means May check

Startup

Has the application finished starting?

The platform keeps waiting, then restarts the instance if the startup budget runs out.

Only that initialisation completed (configuration loaded, first database connection made). Other probes are paused until it succeeds.

Liveness

Is this process able to make progress?

The platform restarts the instance.

Almost nothing: that the event loop responds. A trivial handler that returns 200 is enough.

Readiness

Can this instance serve traffic now?

The platform removes the instance from the load balancer or Service without restarting it.

Whether it is shutting down, whether the dependencies it cannot work without are reachable, and whether warm-up is complete.

The crucial rule is that a liveness check must not depend on anything outside the process. If the liveness endpoint queries the database and the database has a hiccup, every replica fails its liveness check at once, the platform restarts all of them, and a short dependency outage becomes a full outage with a thundering herd of reconnections on top. A dependency failure is a readiness matter: the instance stays alive, is taken out of rotation, and rejoins when the dependency recovers. A liveness endpoint that answers at all proves the event loop is not blocked, which is the only failure a restart actually fixes.

Readiness has the opposite design pressure. It should be honest about what the instance needs, but it should check quickly and cheaply, because the platform calls it every few seconds on every replica:

  • Use a short timeout on each dependency check (a second or less), so a hung dependency makes the probe fail instead of hang.

  • Prefer checking connection state the driver already tracks over issuing a heavy query. A ping command is enough for MongoDB.

  • Check only hard dependencies. If the API can still serve useful responses without the cache, do not let a cache outage mark the instance not ready.

  • Report each dependency separately in the body, so an operator reading the response sees which one failed. Return the detail only to the platform and the monitoring network, not to the public internet.

Endpoint conventions

There is no mandatory path, but conventions exist and following them makes the endpoints recognisable:

Convention Notes

/livez, /readyz

The names the Kubernetes API server itself uses for its own probes. Clear, and they leave /health free for human-facing status pages.

/healthz

The older Kubernetes spelling, now deprecated for the API server but still the most widespread name in applications. Often used as the single combined endpoint.

/health, /ping, /status

Common in frameworks and monitoring tools. Fine if the team agrees on it.

HEAD support

Express answers HEAD for any GET route automatically, which suits load balancers that only need the status line.

Status codes

200 for healthy, 503 for not ready. Probes of every common platform look at the status code, not the body.

Cache-Control: no-store

Prevents an intermediate cache from serving a stale answer, which would hide the failure.

Body

A small JSON object, for example { "status": "ok" }. Machine-readable detail is optional; the status code is the contract.

A few placement rules prevent surprises:

  • Mount the health router before the authentication, rate limiting, session and body-parsing middleware. A probe has no credentials and is called every few seconds from the same address, so a rate limiter or a session store lookup would turn the probe into the thing that fails. The ordering rules are in Middleware Fundamentals.

  • Skip the endpoints in the request logger (the skip option of morgan, or the autoLogging.ignore option of pino-http), or the log fills with probe lines. See Debugging & Logging.

  • Keep them off the public route table when possible, either on a separate internal port or behind the ingress rules. Details leaked by a verbose readiness body, such as dependency host names, are useful to an attacker.

  • Return from them quickly even under load. A probe that is slower than its timeout during a traffic spike causes the platform to remove capacity exactly when it is needed.

An IETF draft for a standard application/health+json response format exists, but it has not been published as an RFC, and no mainstream platform requires it, so the examples here use plain JSON.

A health router for the Bookshelf API

The router below separates the three probes, tracks a shuttingDown flag that the shutdown code sets, and runs the dependency checks in parallel, each with a deadline. The checks are injected, so the same router works in production and in tests.

// src/health.js
import { Router } from 'express';

function withTimeout(promise, ms) {
  let timer;
  const deadline = new Promise((_, reject) => {
    timer = setTimeout(() => reject(new Error('timeout')), ms);
  });
  return Promise.race([promise, deadline]).finally(() => clearTimeout(timer));
}

/**
 * @param {object} options
 * @param {{ shuttingDown: boolean }} options.state mutable state shared with the shutdown code
 * @param {Record<string, () => Promise<unknown>>} [options.checks] hard dependencies for readiness
 * @param {number} [options.checkTimeoutMs] deadline for each check
 */
export function createHealthRouter({ state, checks = {}, checkTimeoutMs = 1000 }) {
  const router = Router();

  router.use((req, res, next) => {
    res.set('Cache-Control', 'no-store');
    next();
  });

  // Liveness: the event loop answers. Never look at dependencies here.
  router.get('/livez', (req, res) => {
    res.json({ status: 'ok' });
  });

  // Readiness: not shutting down, and every hard dependency answers in time.
  router.get('/readyz', async (req, res) => {
    if (state.shuttingDown) {
      res.status(503).json({ status: 'shutting-down' });
      return;
    }

    const entries = await Promise.all(
      Object.entries(checks).map(async ([name, check]) => {
        try {
          await withTimeout(check(), checkTimeoutMs);
          return [name, 'up'];
        } catch {
          return [name, 'down'];
        }
      }),
    );

    const dependencies = Object.fromEntries(entries);
    const ready = entries.every(([, status]) => status === 'up');
    res.status(ready ? 200 : 503).json({ status: ready ? 'ready' : 'not-ready', dependencies });
  });

  return router;
}

Two details are specific to Express 5. The readiness handler is an async function and has no try/catch around the whole body: if it rejected, the router would forward the rejection to the error handlers, whereas on Express 4 the request would hang until the probe timed out and the rejection would surface as an unhandled promise rejection. The route also never calls next() after responding, so there is no risk of a second response from a later layer. For the handler rules, see Error Handling.

Signals: SIGTERM and SIGINT

A process learns that it should stop through a signal:

Signal Who sends it and what it means

SIGTERM

The polite request to terminate. Sent by kill with no arguments, by docker stop, by Kubernetes when a Pod is deleted, by systemd on systemctl stop, and by most platform-as-a-service schedulers. The signal that graceful shutdown must handle.

SIGINT

The interrupt signal, sent when the user presses Ctrl+C in a terminal. Handle it the same way as SIGTERM so that local development exercises the production code path. PM2 also uses it to stop a process.

SIGKILL

Immediate termination by the kernel. It cannot be caught, blocked or handled. Sent after the grace period expires. No cleanup code runs.

SIGHUP, SIGUSR2

Used by some tools to request a reload or restart, for example nodemon restarts a process with SIGUSR2. Not needed in production.

Node.js has no built-in shutdown logic for SIGTERM or SIGINT: by default the signal simply terminates the process. As soon as the program registers a listener with process.on for a signal, that default action is suppressed, and the process stays alive until the program ends it. This has three consequences:

  • The listener takes over the responsibility for ending the process. If it does not call process.exit() or let the event loop empty, the process never stops, and the platform has to wait for the grace period to expire and send SIGKILL.

  • Register the listener once, early, and make the shutdown function idempotent, because a second signal arrives while the first shutdown is still running (an impatient developer pressing Ctrl+C again, or a scheduler retrying).

  • Listeners run on the event loop. A shutdown function that is itself blocked by a synchronous call cannot make progress.

On Windows, SIGTERM is not delivered the way it is on POSIX systems; Node.js only emulates SIGINT and SIGBREAK for a console, and a process stopped with taskkill is terminated without a signal. This matters only for local development on Windows, because production containers run Linux.

The process must receive the signal

Handling the signal is useless if it never arrives. In a container the main process runs as PID 1, and two things can go wrong:

  • The kernel treats PID 1 specially: signals with no handler registered are ignored for it instead of terminating it. A Node.js program that registers its handlers is fine, but one that does not will not respond to docker stop at all and will be killed after the timeout. Always register the handlers.

  • The container command may start a wrapper instead of Node.js. CMD npm start or a shell-form command starts a shell or npm as PID 1, and the signal goes to that process, not to node. Depending on the wrapper, it is not forwarded reliably. Use the exec form, CMD ["node", "src/server.js"], so that Node.js is PID 1, or run the process under a minimal init such as tini (docker run --init, or init: true in Compose) when the application spawns child processes that need to be reaped.

The shutdown sequence

A graceful shutdown is a short fixed sequence. The order matters, because each step prepares the next:

  1. Mark the instance not ready. From this moment /readyz answers 503, and new connections should stop being routed to it.

  2. Keep serving for a short readiness delay. Load balancers and Kubernetes remove an instance from rotation asynchronously, so requests are still arriving for a few seconds after the signal. Closing the listener immediately turns those into connection errors.

  3. Stop accepting connections and close idle ones. Call server.close() and server.closeIdleConnections().

  4. Let in-flight requests finish. Requests already being handled run to completion. Responses carry Connection: close so that a keep-alive client does not reuse the socket for another request.

  5. Force the stragglers after a deadline. Call server.closeAllConnections() once the drain timeout expires.

  6. Close the resources behind the server, in the reverse order of creation: queue workers, caches, then database pools. Closing them earlier would break the requests still in flight.

  7. Exit with code 0 after a clean drain, or a non-zero code when the deadline was missed or the shutdown was caused by an error.

The following figure puts the sequence on a timeline, with the load balancer, the Express process and a long-running request.

sequenceDiagram participant P as Platform participant L as Load balancer participant A as Express process participant C as Client with request in flight P->>A: SIGTERM A->>A: state.shuttingDown set, readyz answers 503 L->>A: GET readyz returns 503 L->>L: instance removed from rotation Note over A: readiness delay, still accepting requests A->>A: server.close and closeIdleConnections A-->>C: response sent with Connection close C->>A: socket closed by the client Note over A: drain deadline not reached A->>A: close queue workers, then database pool A->>P: exit with code 0 Note over P,A: terminationGracePeriodSeconds not exhausted, no SIGKILL

When the deadline is missed the picture differs only at the end: the process calls closeAllConnections(), the clients of the interrupted requests see a connection reset, and the process exits with a non-zero code before the platform has to send SIGKILL.

server.close and the connection methods

app.listen() is a thin wrapper that creates a node:http server, calls listen() on it and returns it. That returned http.Server is the object to shut down, and it has four methods that matter. Keep the return value of app.listen() in a variable, because Express offers no app.close().

Method Behaviour

server.close(callback)

Stops the server from accepting new connections. The callback runs when all connections have ended, with an error argument if the server was not running (ERR_SERVER_NOT_RUNNING). On Node.js 19 and later it also closes idle connections, those that are not sending or waiting for a request. On Node.js 18 it does not, so keep-alive connections that are merely idle can hold the callback back for as long as their keep-alive timeout.

server.closeIdleConnections()

Closes the connections that are not currently serving a request. Added in Node.js 18.2. Calling it explicitly after close() is harmless on every supported version and makes the behaviour independent of the Node.js minor.

server.closeAllConnections()

Closes every connection, including those with a request in flight. Added in Node.js 18.2. This is the forced step after the drain deadline: the interrupted clients get a connection reset.

server.listening

true while the server is accepting connections. Useful to make shutdown idempotent.

Keep these points in mind:

  • server.close() takes a callback, not a promise. Wrap it in a Promise to use it with await, and resolve or reject from the callback.

  • The callback fires only when the last connection is gone, not when the last request finishes. A client that holds an idle keep-alive socket keeps the server open until the socket closes, unless closeIdleConnections() closes it.

  • A connection that is busy keeps its request. If the request takes minutes, server.close() will wait minutes, which is why the deadline and closeAllConnections() exist.

  • Calling server.close() does not affect connections that were upgraded to another protocol. A WebSocket or a server other than HTTP running on the same socket is no longer tracked as an HTTP request, so close those yourself (for example with the ws server’s own close()). Server-sent events responses are ordinary long-lived requests, which closeIdleConnections() does not close; end them explicitly with res.end() when shutdown starts. See Real-time: WebSockets & SSE.

  • Express 5 passes errors from listen to the callback given to app.listen(). Express 4 does not; there, listen failures such as EADDRINUSE arrive as an error event on the server, and without a listener they crash the process.

Keep-alive connections and timeouts

HTTP/1.1 keeps connections open between requests by default, which is what makes shutdown subtle: a connection that has just finished its response is idle and open, and the client is free to send another request on it at any moment, even after server.close() was called. Three mechanisms handle this:

  1. The shutdown middleware sets Connection: close on responses while the server is draining. Node.js then closes the socket after the response, and the client opens a new connection (which the load balancer routes elsewhere) for its next request.

  2. closeIdleConnections() closes whatever is idle at the moment of the call.

  3. The keep-alive timeout of the server closes idle connections after a while whatever else happens.

The timeouts of the http.Server also affect both normal operation and shutdown:

Property Default Meaning

keepAliveTimeout

5000 ms

How long an idle keep-alive connection is kept open waiting for the next request.

headersTimeout

60000 ms

How long the server waits to receive the complete request headers. Should be greater than keepAliveTimeout.

requestTimeout

300000 ms

How long the server waits for the entire request to arrive. Since Node.js 18 it is enabled by default; 0 disables it.

timeout

0 (disabled)

Socket inactivity timeout. Leave it disabled and use per-route timeouts if needed.

connectionsCheckingInterval

30000 ms

How often the server checks for connections that exceeded headersTimeout or requestTimeout.

A well-known production problem is intermittent 502 or 504 errors from a load balancer. It happens when the application’s keepAliveTimeout is shorter than the idle timeout of the load balancer in front of it: the balancer reuses a connection that Node.js has just closed. The fix is to make the application the slower party. Amazon’s Application Load Balancer, for instance, defaults to a 60 second idle timeout, so set server.keepAliveTimeout to 65 seconds and server.headersTimeout slightly above it. The same reasoning applies to every proxy and balancer: read its idle timeout and stay above it.

A complete shutdown module

The module below implements the sequence from the previous sections. It knows nothing about Mongoose or any other resource: closers are registered by the application, run in reverse order, and each one is protected so that a failing closer does not stop the others.

// src/lifecycle.js
import { setTimeout as sleep } from 'node:timers/promises';

/**
 * Shared state read by the readiness endpoint and the drain middleware.
 */
export function createLifecycleState() {
  return { shuttingDown: false };
}

/**
 * While the server is draining, ask clients to close their keep-alive sockets.
 */
export function drainMiddleware(state) {
  return (req, res, next) => {
    if (state.shuttingDown) {
      res.set('Connection', 'close');
    }
    next();
  };
}

function closeServer(server) {
  return new Promise((resolve, reject) => {
    server.close((error) => (error ? reject(error) : resolve()));
    // Node.js 18 does not close idle connections in close(); do it explicitly.
    server.closeIdleConnections();
  });
}

/**
 * @param {object} options
 * @param {import('node:http').Server} options.server
 * @param {{ shuttingDown: boolean }} options.state
 * @param {Array<{ name: string, close: () => Promise<unknown> | unknown }>} [options.closers]
 * @param {number} [options.readinessDelayMs] keep serving while the platform removes the instance
 * @param {number} [options.drainTimeoutMs] after this, in-flight requests are cut off
 * @param {number} [options.hardExitMs] after this, the process exits whatever is pending
 */
export function createShutdown({
  server,
  state,
  closers = [],
  readinessDelayMs = 5000,
  drainTimeoutMs = 25000,
  hardExitMs = 35000,
  logger = console,
}) {
  let started = false;

  return async function shutdown(reason, exitCode = 0) {
    if (started) {
      logger.warn(`${reason} received while shutting down, exiting immediately`);
      process.exit(1);
    }
    started = true;
    logger.info(`${reason} received: draining`);

    // Last resort: never outlive the platform's grace period.
    setTimeout(() => {
      logger.error('Hard exit: shutdown did not finish in time');
      process.exit(1);
    }, hardExitMs).unref();

    let code = exitCode;

    // 1. Not ready any more; 2. keep serving while the load balancer notices.
    state.shuttingDown = true;
    await sleep(readinessDelayMs);

    // 3 and 4. Stop accepting connections and wait for in-flight requests...
    const drainDeadline = setTimeout(() => {
      logger.warn('Drain timeout reached: closing remaining connections');
      code = code || 1;
      server.closeAllConnections(); // 5. ...but only until the deadline.
    }, drainTimeoutMs);

    try {
      await closeServer(server);
    } catch (error) {
      logger.error('Error while closing the server', error);
      code = code || 1;
    } finally {
      clearTimeout(drainDeadline);
    }

    // 6. Close resources in reverse order of creation.
    for (const closer of [...closers].reverse()) {
      try {
        await closer.close();
        logger.info(`Closed ${closer.name}`);
      } catch (error) {
        logger.error(`Error while closing ${closer.name}`, error);
        code = code || 1;
      }
    }

    // 7. Exit explicitly: a stray handle must not keep a finished process alive.
    process.exit(code);
  };
}

/**
 * Register the process-level handlers once.
 */
export function registerShutdownHandlers(shutdown, logger = console) {
  for (const signal of ['SIGTERM', 'SIGINT']) {
    process.on(signal, () => {
      void shutdown(signal);
    });
  }

  // An uncaught exception leaves the process in an unknown state: log it, stop quickly with a failure code.
  process.on('uncaughtException', (error) => {
    logger.error('Uncaught exception', error);
    void shutdown('uncaughtException', 1);
  });
}

The pieces deserve a short justification:

  • unref() on the hard-exit timer lets the process end normally if everything finishes earlier, instead of the timer itself holding the event loop open.

  • process.exit(code) at the end is deliberate. Sockets, timers or a client library with a forgotten handle can keep the event loop alive, and in a container a process that finished its work but does not exit costs the whole grace period. The exit is safe here because all the resources were closed first.

  • The second signal calls process.exit(1) immediately. It gives an operator who presses Ctrl+C twice the fast exit they expect, and it is a consequence of making the function idempotent rather than ignoring repeated signals.

  • uncaughtException does not continue serving. The Node.js documentation is explicit that the handler is not a way to resume normal operation; log the error and stop, and let the platform start a clean replacement. Unhandled promise rejections already raise an uncaughtException by default since Node.js 15, so one handler covers both.

  • drainTimeoutMs is shorter than hardExitMs, which is shorter than the platform’s grace period. The three numbers form a budget, discussed in The time budget.

Wiring it together

The server file creates the application, connects the dependencies, starts listening and registers the handlers. The drain middleware and the health router are the first layers, so they apply to everything.

// src/app.js
import express from 'express';
import { createHealthRouter } from './health.js';
import { drainMiddleware } from './lifecycle.js';

export function createApp({ state, checks }) {
  const app = express();
  app.disable('x-powered-by');

  app.use(drainMiddleware(state));
  app.use(createHealthRouter({ state, checks }));

  app.get('/books', (req, res) => {
    res.json([{ id: 1, title: 'Example book' }]);
  });

  return app;
}
// src/server.js
import mongoose from 'mongoose';
import { createApp } from './app.js';
import { createLifecycleState, createShutdown, registerShutdownHandlers } from './lifecycle.js';

const port = Number(process.env.PORT ?? 3000);
const mongoUri = process.env.MONGODB_URI ?? 'mongodb://127.0.0.1:27017/bookshelf';

const state = createLifecycleState();

await mongoose.connect(mongoUri, { serverSelectionTimeoutMS: 5000 });

const app = createApp({
  state,
  checks: {
    mongodb: () => mongoose.connection.db.command({ ping: 1 }),
  },
});

const server = app.listen(port, (error) => {
  // Express 5 passes listen errors (EADDRINUSE and others) to this callback.
  if (error) {
    console.error('Cannot listen', error);
    process.exit(1);
  }
  console.log(`Bookshelf API listening on port ${port}`);
});

// Stay above the idle timeout of the load balancer in front of the service.
server.keepAliveTimeout = 65_000;
server.headersTimeout = 66_000;

const shutdown = createShutdown({
  server,
  state,
  closers: [
    // Registered in creation order; closed in reverse order.
    { name: 'mongodb', close: () => mongoose.connection.close() },
  ],
  readinessDelayMs: Number(process.env.SHUTDOWN_READINESS_DELAY_MS ?? 5000),
  drainTimeoutMs: Number(process.env.SHUTDOWN_DRAIN_TIMEOUT_MS ?? 25000),
  hardExitMs: Number(process.env.SHUTDOWN_HARD_EXIT_MS ?? 35000),
});

registerShutdownHandlers(shutdown);

Splitting createApp() from the file that listens is the same structure recommended for testing in Testing; it lets a test import the application without opening a port. Where the database connection is established is also worth a decision: connecting before listen(), as above, means the process never reports ready before the database is reachable, at the cost of a longer start, which is exactly what a startup probe (Kubernetes probes) covers. Connecting after listen() and letting /readyz report 503 until the connection exists is the alternative; it starts faster but requires every route to cope with a not-yet-connected database.

Mongoose 8 closes with await mongoose.connection.close() (the callback form was removed in Mongoose 7), and the command({ ping: 1 }) call is the same command the MongoDB shell uses for its own health checks. See MongoDB & Mongoose and Database Integration for the connection options.

To try it locally, start the server, send a request that takes a while in another terminal, and stop the server with Ctrl+C or kill -TERM <pid>: /readyz starts answering 503 at once, the request in progress still completes, and the process logs the closers and exits with code 0 roughly after the readiness delay.

Closing what sits behind the server

The server is only one of the things that must stop cleanly. Anything that holds a socket, a timer or buffered work needs a closer, and the order is the reverse of the dependencies: whatever uses a resource closes first.

Resource How to close Notes

MongoDB (Mongoose)

await mongoose.connection.close()

Waits for in-flight operations. See above.

PostgreSQL (pg)

await pool.end()

Waits for checked-out clients to be released, so release them in a finally block.

MySQL (mysql2)

await pool.end()

Same rule.

Redis (redis, ioredis)

await client.quit()

quit flushes pending commands, disconnect drops them.

BullMQ workers

await worker.close()

Stops taking jobs and waits for active ones. Close producers (queues) after workers.

WebSocket server (ws)

wss.close() plus ending each client

Not covered by server.close(). Send a close frame so clients reconnect elsewhere.

Server-sent events

res.end() on each open stream

Long-lived responses keep the server open until they end.

Timers and intervals

clearInterval() or timer.unref()

A background interval keeps the event loop alive, and a job half-way through a cycle should finish first.

Log transports (pino)

logger.flush() or close the destination

Asynchronous destinations can lose the last lines.

Two rules apply to all of them. Run the closers sequentially, not in parallel, when one depends on another, and give each a time limit inside the overall budget so that a hung client library cannot consume it all. The hard exit timer in the module above is the backstop for exactly that case.

Three kinds of "not serving"

A frequent source of confusion is that an instance can be unhealthy in three different ways, each needing a different response from the platform:

State Signal to the platform Action expected

Starting

Startup probe failing, readiness failing

Wait. Do not restart until the startup budget is exhausted.

Stuck (event loop blocked, deadlock)

Liveness failing

Restart the instance.

Dependency down or overloaded

Readiness failing, liveness passing

Stop sending traffic, keep the instance running.

Shutting down

Readiness failing on purpose

Stop sending traffic; the process exits by itself.

If these were collapsed into a single endpoint, as in the common single /health pattern, the platform could not distinguish "restart me" from "leave me alone for a minute", which is the situation that produces restart loops.

Docker and Compose

A container is stopped with docker stop, which sends SIGTERM to PID 1 and waits for the stop timeout, ten seconds by default, before sending SIGKILL. Two settings keep that interval compatible with the application’s budget: the --stop-timeout option (or stop_grace_period in Compose) lengthens the wait, and the shutdown timeouts above must be shorter than it.

# Dockerfile (excerpt)
FROM node:24-slim
WORKDIR /app
ENV NODE_ENV=production
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY src ./src
USER node
EXPOSE 3000

# The slim image has no curl: use Node.js itself for the container health check.
HEALTHCHECK --interval=15s --timeout=3s --start-period=20s --retries=3 \
  CMD node -e "fetch('http://127.0.0.1:3000/livez').then((r) => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"

# Exec form: node is PID 1 and receives SIGTERM directly.
CMD ["node", "src/server.js"]
# compose.yaml (excerpt)
services:
  api:
    build: .
    init: true            # tini reaps child processes and forwards signals
    stop_grace_period: 40s
    environment:
      MONGODB_URI: mongodb://mongo:27017/bookshelf
      SHUTDOWN_DRAIN_TIMEOUT_MS: "25000"
      SHUTDOWN_HARD_EXIT_MS: "35000"
    healthcheck:
      test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000/livez').then((r) => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"]
      interval: 15s
      timeout: 3s
      retries: 3

The Docker HEALTHCHECK marks a container healthy or unhealthy for docker ps and for Compose depends_on conditions; it does not restart a container by itself outside Swarm, so it is a status indicator rather than a liveness mechanism. It targets /livez for the same reason a Kubernetes liveness probe does. The wider container build is covered in Docker: Resources, Logging and Monitoring and the Dockerfile itself in Deployment & Containers.

Kubernetes probes

Kubernetes consumes exactly the endpoints built above. Each probe type maps to one of the three checks and has its own timing fields; the full list of mechanisms (httpGet, tcpSocket, exec, grpc) and fields is in Kubernetes: Health probes and lifecycle. For an Express service httpGet is the natural choice, because the application already speaks HTTP.

Probe Endpoint and fields Reasoning

startupProbe

/livez, periodSeconds: 2, failureThreshold: 30

Gives the application up to a minute to connect to its dependencies before liveness takes over. Liveness and readiness are not evaluated until it succeeds.

livenessProbe

/livez, periodSeconds: 10, timeoutSeconds: 2, failureThreshold: 3

Restarts a stuck process after roughly thirty seconds. Tolerant on purpose: a restart is expensive.

readinessProbe

/readyz, periodSeconds: 5, timeoutSeconds: 2, failureThreshold: 2

Removes the Pod from the Service endpoints after about ten seconds of failures. Quicker than liveness because the cost of being wrong is small.

# deployment.yaml (excerpt)
apiVersion: apps/v1
kind: Deployment
metadata:
  name: bookshelf-api
spec:
  replicas: 3
  selector:
    matchLabels:
      app: bookshelf-api
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxUnavailable: 0
      maxSurge: 1
  template:
    metadata:
      labels:
        app: bookshelf-api
    spec:
      terminationGracePeriodSeconds: 45
      containers:
        - name: api
          image: registry.example.com/bookshelf-api:1.0.0
          ports:
            - name: http
              containerPort: 3000
          env:
            - name: SHUTDOWN_READINESS_DELAY_MS
              value: "0"            # the preStop sleep already waits for endpoint removal
            - name: SHUTDOWN_DRAIN_TIMEOUT_MS
              value: "25000"
            - name: SHUTDOWN_HARD_EXIT_MS
              value: "35000"
          startupProbe:
            httpGet:
              path: /livez
              port: http
            periodSeconds: 2
            failureThreshold: 30
          livenessProbe:
            httpGet:
              path: /livez
              port: http
            periodSeconds: 10
            timeoutSeconds: 2
            failureThreshold: 3
          readinessProbe:
            httpGet:
              path: /readyz
              port: http
            periodSeconds: 5
            timeoutSeconds: 2
            failureThreshold: 2
          lifecycle:
            preStop:
              sleep:
                seconds: 5

With maxUnavailable: 0 and maxSurge: 1, a rolling update starts a new Pod, waits for its readiness probe to pass, and only then terminates an old Pod, so capacity never drops during the rollout. That guarantee depends on the readiness probe being honest, which is why it checks the dependencies.

What Kubernetes does when a Pod is deleted

Pod termination and endpoint removal are two independent processes that start at the same moment, and there is no ordering guarantee between them. When a Pod is deleted, Kubernetes:

  1. Marks the Pod Terminating, and the endpoints controller starts removing the Pod from the Service endpoints, from which kube-proxy, ingress controllers and service meshes learn the new routing. This propagates to every node asynchronously and can take a few seconds.

  2. At the same time the kubelet runs the preStop hook, if any, and waits for it to return.

  3. Then the kubelet sends SIGTERM to the main process of each container.

  4. When terminationGracePeriodSeconds has elapsed since the deletion started, whatever is still running receives SIGKILL.

Because step 1 is not synchronised with steps 2 and 3, an application that closes its listener immediately on SIGTERM may still receive requests from components that have not yet seen the endpoint removal, and those fail. There are two ways to cover the gap, and the manifest above uses the first:

  • A preStop hook with a sleep action pauses the container for a few seconds before SIGTERM is delivered, without needing a shell or a script in the image. The sleep action for lifecycle hooks is a relatively recent Kubernetes feature (beta in 1.30, and stable in later releases), so on an older cluster use an exec hook that runs sleep from a shell if the image has one.

  • The readiness delay inside the application, the SHUTDOWN_READINESS_DELAY_MS setting. This works on every platform, including those with no preStop hook, and is the better choice when the same image runs on Kubernetes, Compose and a PaaS. Use one or the other, not both, because the delays add up inside the grace period.

The time budget

The numbers need to fit together. All of the following start counting when the Pod is deleted, and the grace period covers all of them:

Phase Value in the example Notes

preStop sleep

5 s

Time for the endpoint removal to propagate. Counted inside the grace period.

Readiness delay in the application

0 s

Zero here because the preStop sleep already did the waiting.

Drain timeout (SHUTDOWN_DRAIN_TIMEOUT_MS)

25 s, measured from the signal

After this, closeAllConnections() cuts the remaining requests off.

Closing resources

a few seconds

Pool closing is normally quick once the requests are gone.

Hard exit (SHUTDOWN_HARD_EXIT_MS)

35 s, measured from the signal

Process-level backstop.

terminationGracePeriodSeconds

45 s

Must exceed preStop plus the hard exit: 5 + 35 = 40 s, leaving five seconds of margin.

The rule is therefore preStop + hard exit < grace period, with the drain timeout comfortably longer than your slowest legitimate request. Also remember that the default terminationGracePeriodSeconds is 30 seconds. An API with 30-second requests and the default is guaranteed to be killed in the middle of one.

Termination can start for reasons other than a deployment: autoscaler scale-down, node drain for maintenance, spot or preemptible node reclamation (which may give much less than 30 seconds of notice) and eviction under memory pressure. The shutdown code is therefore the same code path for all of them, and it should be exercised regularly rather than only at release time.

Probe pitfalls that cause real incidents

  • A liveness probe that checks the database restarts every replica during a database outage (see Liveness, readiness and startup).

  • A readiness probe slower than its timeoutSeconds during a load peak removes Pods exactly when they are most needed, which increases the load on the others and cascades.

  • No startup probe and a liveness probe with a short delay kills a slow-starting application in a restart loop that never lets it finish booting.

  • A probe that goes through the same event loop as an overloaded handler fails when the process is merely busy. That is correct for liveness only if busy means stuck; if a legitimate CPU-bound job blocks the loop for longer than periodSeconds * failureThreshold, move the work to a worker thread (see Performance Best Practices).

  • A probe port that differs from the application port because it was copied from a different service.

  • Probes that need authentication, or that are rate limited, as discussed in Endpoint conventions.

Load balancers outside Kubernetes

The same logic applies to every balancer, only the names differ. Each has a health-check setting (path, interval, threshold, expected status codes) and a connection-draining setting that controls how long it keeps existing connections to a deregistered target. Examples are the deregistration delay of AWS target groups (300 seconds by default), the connection draining timeout of Google Cloud backend services and the drain state of HAProxy servers. Match the application’s budget to the balancer’s setting: an application that drains in 25 seconds behind a balancer that keeps sending traffic for five minutes still sees traffic after it has closed its listener. For a service that sits behind a reverse proxy, also make sure trust proxy is set correctly, so that the health requests and the real ones are seen with the right client address; see Behind Proxies.

Using a library: Terminus and friends

The official Express page on this subject recommends not writing the plumbing by hand and lists a few packages that do it. The best known is @godaddy/terminus, which attaches to the http.Server and provides health check endpoints, signal handling, a shutdown callback and a timeout. The equivalent of the previous sections looks like this:

// src/server-terminus.js
import { createTerminus } from '@godaddy/terminus';
import mongoose from 'mongoose';
import { createServer } from 'node:http';
import express from 'express';

const app = express();
app.get('/books', (req, res) => {
  res.json([]);
});

const server = createServer(app);

await mongoose.connect(process.env.MONGODB_URI ?? 'mongodb://127.0.0.1:27017/bookshelf');

createTerminus(server, {
  signal: 'SIGTERM',
  signals: ['SIGINT'],
  healthChecks: {
    '/readyz': async () => {
      await mongoose.connection.db.command({ ping: 1 });
    },
    '/livez': async () => {},
  },
  timeout: 25_000,
  // Give the load balancer time to notice that the instance is going away.
  beforeShutdown: () => new Promise((resolve) => setTimeout(resolve, 5000)),
  onSignal: async () => {
    await mongoose.connection.close();
  },
  onShutdown: async () => {
    console.log('Cleanup finished, server is shutting down');
  },
});

server.listen(Number(process.env.PORT ?? 3000));

Terminus makes the health endpoints answer 503 once shutdown starts, so a Kubernetes readiness probe fails by itself, and it tracks open connections so that the server can close cleanly. The official page also points at other packages for the same job, including lightship (probes plus a shutdown handler designed for Kubernetes) and http-terminator (focused on the server close step). They are worth considering when the team would rather depend on a maintained package than own roughly eighty lines of lifecycle code. The trade-offs are the usual ones: a library hides the sequence, which is convenient until a shutdown misbehaves and someone must reason about it, and the hand-written version is easy to adapt to resources the library does not know about. Whichever is chosen, test the shutdown (Testing health checks and shutdown) and check the package’s release history, since lifecycle packages are small projects and some have long gaps between releases.

Process managers have their own shutdown conventions that should be known when they are used instead of an orchestrator. PM2 sends SIGINT and waits for a kill timeout (1.6 seconds by default, configurable with kill_timeout) before sending SIGKILL, so a drain longer than that must raise the setting; pm2 reload with wait_ready also expects the application to signal readiness explicitly. Systemd sends SIGTERM and waits TimeoutStopSec before SIGKILL.

Testing health checks and shutdown

The health router is a unit; the shutdown sequence is an integration. Both can be tested with the built-in node:test runner and fetch, without extra dependencies. See Testing for the broader strategy.

// test/health.test.js
import assert from 'node:assert/strict';
import { after, before, describe, test } from 'node:test';
import express from 'express';
import { createHealthRouter } from '../src/health.js';

function listen(app) {
  return new Promise((resolve, reject) => {
    const server = app.listen(0, '127.0.0.1', (error) => (error ? reject(error) : resolve(server)));
  });
}

describe('health endpoints', () => {
  const state = { shuttingDown: false };
  let databaseUp = true;
  let server;
  let base;

  before(async () => {
    const app = express();
    app.use(
      createHealthRouter({
        state,
        checks: {
          database: async () => {
            if (!databaseUp) throw new Error('down');
          },
        },
      }),
    );
    server = await listen(app);
    base = `http://127.0.0.1:${server.address().port}`;
  });

  after(() => {
    server.close();
    // fetch keeps connections alive; close them so the test process can exit on every Node.js version.
    server.closeAllConnections();
  });

  test('liveness is 200 and does not depend on dependencies', async () => {
    databaseUp = false;
    const response = await fetch(`${base}/livez`);
    assert.equal(response.status, 200);
    assert.equal(response.headers.get('cache-control'), 'no-store');
    databaseUp = true;
  });

  test('readiness is 200 when the dependencies are up', async () => {
    const response = await fetch(`${base}/readyz`);
    assert.equal(response.status, 200);
    assert.deepEqual(await response.json(), { status: 'ready', dependencies: { database: 'up' } });
  });

  test('readiness is 503 when a dependency is down', async () => {
    databaseUp = false;
    const response = await fetch(`${base}/readyz`);
    assert.equal(response.status, 503);
    assert.equal((await response.json()).dependencies.database, 'down');
    databaseUp = true;
  });

  test('readiness is 503 while shutting down', async () => {
    state.shuttingDown = true;
    const response = await fetch(`${base}/readyz`);
    assert.equal(response.status, 503);
    state.shuttingDown = false;
  });
});

The after hook shows why closeAllConnections() exists: fetch reuses connections, and on Node.js 18 a plain server.close() would leave the test runner waiting for the keep-alive socket to time out.

For the signal path, a test spawns the real server as a child process, sends it SIGTERM while a slow request is in progress and asserts that the request finishes and the exit code is 0:

// test/shutdown.test.js
import assert from 'node:assert/strict';
import { spawn } from 'node:child_process';
import { once } from 'node:events';
import { test } from 'node:test';
import { setTimeout as sleep } from 'node:timers/promises';

test('SIGTERM drains the in-flight request and exits with 0', async () => {
  const child = spawn(process.execPath, ['test/fixtures/slow-server.js'], {
    env: { ...process.env, PORT: '3456', SHUTDOWN_READINESS_DELAY_MS: '100' },
    stdio: ['ignore', 'pipe', 'inherit'],
  });
  await once(child.stdout, 'data'); // the fixture prints a line once it is listening

  const inFlight = fetch('http://127.0.0.1:3456/slow'); // the fixture answers after about one second
  await sleep(200);
  child.kill('SIGTERM');

  const response = await inFlight;
  assert.equal(response.status, 200);

  const [code] = await once(child, 'exit');
  assert.equal(code, 0);
});

The fixture is a few lines that build the app with createApp(), add a route that waits one second before answering, listen on PORT, print a line and register the handlers from src/lifecycle.js. Running it as a separate process is what makes the test meaningful, since signals cannot be sent to the test runner itself without terminating it.

Express 4 versus 5 and what changed recently

Topic Express 4 Express 5

Failures of app.listen

Raised as an error event on the returned server; without a listener the process crashes with an unhandled error. The callback takes no arguments.

The callback receives the error argument for failures such as EADDRINUSE. Handle it there.

Rejected promise in a health handler

Not forwarded; the request hangs and an unhandled rejection is raised (which terminates the process on Node.js 15 and later).

Forwarded to the error handlers like a thrown error, so an async readiness handler is safe.

Closing the server

Same http.Server, nothing in Express.

Same. There is still no app.close().

Path syntax for the health routes

Plain strings such as /livez work.

Plain strings work unchanged. Only wildcard and optional syntax changed, which does not affect these routes.

Changes outside Express that affect this page:

  • Node.js 18.2 added server.closeIdleConnections() and server.closeAllConnections(), which turned the old need for socket-tracking packages into two method calls.

  • Node.js 19 made server.close() close idle connections itself, so on current versions a plain close() no longer hangs because of idle keep-alive sockets. The explicit closeIdleConnections() in the module above stays for Node.js 18.

  • Node.js 18 enabled requestTimeout by default (300 seconds) and raised the default headersTimeout to 60 seconds; long uploads or slow clients can now be cut off by the server, which interacts with the drain timeout.

  • Node.js 15 made unhandled promise rejections terminate the process by default, which is why code that relied on a leaked rejection to go unnoticed now fails in production.

  • Kubernetes added grpc probes (stable in 1.27), a native sleep action for lifecycle hooks, and a startup probe (stable since 1.20) that makes long-start applications simple to support without inflating the liveness delay. Sidecar containers (stable from 1.33) also terminate in a defined order, after the main containers.

  • Mongoose 7 and later removed callback support, so closers are promise-based, as in the examples.

Where the books are outdated

The four books consulted for this section target Express 4.0 to 4.18 and Node.js 0.10 to 20, and concentrate on building features. Operational behaviour is the area where they say the least, and where the ecosystem moved the most:

Older practice Where it appears Current behaviour

Starting the server with app.listen() and stopping it only by killing the process, with no signal handler and no cleanup.

Bugl, chapter 3; Monteiro 2014

Handle SIGTERM and SIGINT, stop accepting connections, drain, close the pools and exit. Without it, every deployment risks cutting off requests.

app.listen() without handling the failure of the listen call.

Bugl, chapter 3

On Express 5 the callback receives the error (for example EADDRINUSE); on Express 4 listen for the server error event.

Running the application in production under forever or nodemon, or relying on a hand-started terminal session.

Monteiro 2014; Monteiro 2016

These tools are for development or are no longer maintained. Use the platform’s supervisor (systemd, a container orchestrator, a PaaS) with health probes, or node --watch locally. nodemon signals a restart with SIGUSR2, which is not what production sends.

No health endpoints: the only monitor is whether the port answers, or a hand-made /ping returning text.

All four books

Separate liveness, readiness and startup endpoints, status codes as the contract, and dependency checks confined to readiness.

Tracking sockets by hand (a connections array with socket.destroy()) or installing a package to force-close a server.

Community tutorials of the Express 4 era

closeIdleConnections() and closeAllConnections() are built into Node.js 18.2 and later.

Hosting on Heroku free dynos, with a 30-second SIGTERM window as the only lifecycle knowledge.

Monteiro 2016 deployment chapters

The free tier ended in November 2022. Every current platform documents its own grace period; read it and size the shutdown budget to it.

Common pitfalls

Symptom Usual cause Fix

Replicas restart in a loop during a database outage.

The liveness probe checks the database.

Move dependency checks to readiness; keep liveness trivial.

docker stop takes ten seconds and the exit code is 137.

No SIGTERM handler, or the signal never reaches Node.js (CMD npm start, shell form).

Register the handlers and use the exec form CMD ["node", "src/server.js"], or init: true.

A rolling deployment produces a burst of 502 errors.

The listener closes at once on SIGTERM while the load balancer still routes to the Pod.

Add a preStop sleep or a readiness delay, and fail /readyz before closing.

The shutdown callback never fires, and the process is killed after the grace period.

Idle keep-alive connections (Node.js 18), SSE streams or WebSockets keep the server open.

Call closeIdleConnections(), set Connection: close while draining, end long-lived streams, force with closeAllConnections() after a deadline.

Intermittent 502 or 504 from the load balancer under steady traffic.

keepAliveTimeout shorter than the balancer’s idle timeout.

Set keepAliveTimeout above the balancer’s idle timeout and headersTimeout above keepAliveTimeout.

Requests fail with errors from the database during shutdown.

Pools were closed before the server finished draining.

Close the server first, then the resources, in the reverse order of creation.

The process ignores Ctrl+C after the first press.

The handler is registered but never ends the process, or the drain is stuck.

Make the shutdown idempotent and force an exit on a repeated signal, plus a hard exit timer.

Pods are killed in the middle of a long request.

terminationGracePeriodSeconds left at the default 30 s, shorter than the drain deadline.

Raise the grace period above preStop plus the hard exit, and lower the timeouts to match.

Probe requests fill the logs or hit the rate limiter.

The health router is mounted after the logger and the limiter.

Mount it first and skip it in the access log.

Readiness stays 200 while the process is shutting down.

The flag is set but the readiness handler never looks at it, or the check results are cached.

Return 503 first thing in the handler when shuttingDown is true, and send Cache-Control: no-store.

The test process hangs after the last test.

The server is closed but fetch keep-alive sockets remain.

Call server.closeAllConnections() after server.close() in the after hook.

uncaughtException handler keeps serving after logging.

The handler is treated as error recovery.

Log, start the shutdown with a failure code and let the platform start a clean process.