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:
-
Has the process started and finished initialising? (startup)
-
Is the process still working, or is it stuck and better restarted? (liveness)
-
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:
-
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.
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 |
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
pingcommand 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 |
|---|---|
|
The names the Kubernetes API server itself uses for its own probes. Clear, and they leave |
|
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. |
|
Common in frameworks and monitoring tools. Fine if the team agrees on it. |
|
Express answers |
Status codes |
|
|
Prevents an intermediate cache from serving a stale answer, which would hide the failure. |
Body |
A small JSON object, for example |
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
skipoption ofmorgan, or theautoLogging.ignoreoption ofpino-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 |
|---|---|
|
The polite request to terminate. Sent by |
|
The interrupt signal, sent when the user presses Ctrl+C in a terminal. Handle it the same way as |
|
Immediate termination by the kernel. It cannot be caught, blocked or handled. Sent after the grace period expires. No cleanup code runs. |
|
Used by some tools to request a reload or restart, for example |
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 sendSIGKILL. -
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 stopat 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 startor a shell-form command starts a shell or npm as PID 1, and the signal goes to that process, not tonode. 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 astini(docker run --init, orinit: truein 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:
-
Mark the instance not ready. From this moment
/readyzanswers503, and new connections should stop being routed to it. -
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.
-
Stop accepting connections and close idle ones. Call
server.close()andserver.closeIdleConnections(). -
Let in-flight requests finish. Requests already being handled run to completion. Responses carry
Connection: closeso that a keep-alive client does not reuse the socket for another request. -
Force the stragglers after a deadline. Call
server.closeAllConnections()once the drain timeout expires. -
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.
-
Exit with code
0after 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.
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 |
|---|---|
|
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 ( |
|
Closes the connections that are not currently serving a request. Added in Node.js 18.2. Calling it explicitly after |
|
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. |
|
|
Keep these points in mind:
-
server.close()takes a callback, not a promise. Wrap it in aPromiseto use it withawait, 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 andcloseAllConnections()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 thewsserver’s ownclose()). Server-sent events responses are ordinary long-lived requests, whichcloseIdleConnections()does not close; end them explicitly withres.end()when shutdown starts. See Real-time: WebSockets & SSE. -
Express 5 passes errors from
listento the callback given toapp.listen(). Express 4 does not; there, listen failures such asEADDRINUSEarrive as anerrorevent 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:
-
The shutdown middleware sets
Connection: closeon 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. -
closeIdleConnections()closes whatever is idle at the moment of the call. -
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 |
|---|---|---|
|
5000 ms |
How long an idle keep-alive connection is kept open waiting for the next request. |
|
60000 ms |
How long the server waits to receive the complete request headers. Should be greater than |
|
300000 ms |
How long the server waits for the entire request to arrive. Since Node.js 18 it is enabled by default; |
|
0 (disabled) |
Socket inactivity timeout. Leave it disabled and use per-route timeouts if needed. |
|
30000 ms |
How often the server checks for connections that exceeded |
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);
});
}
Source: Node.js: server.close()
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. -
uncaughtExceptiondoes 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 anuncaughtExceptionby default since Node.js 15, so one handler covers both. -
drainTimeoutMsis shorter thanhardExitMs, 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) |
|
Waits for in-flight operations. See above. |
PostgreSQL ( |
|
Waits for checked-out clients to be released, so release them in a |
MySQL ( |
|
Same rule. |
Redis ( |
|
|
BullMQ workers |
|
Stops taking jobs and waits for active ones. Close producers (queues) after workers. |
WebSocket server ( |
|
Not covered by |
Server-sent events |
|
Long-lived responses keep the server open until they end. |
Timers and intervals |
|
A background interval keeps the event loop alive, and a job half-way through a cycle should finish first. |
Log transports ( |
|
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
Source: Docker: Dockerfile HEALTHCHECK
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 |
|---|---|---|
|
|
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. |
|
|
Restarts a stuck process after roughly thirty seconds. Tolerant on purpose: a restart is expensive. |
|
|
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:
-
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. -
At the same time the kubelet runs the
preStophook, if any, and waits for it to return. -
Then the kubelet sends
SIGTERMto the main process of each container. -
When
terminationGracePeriodSecondshas elapsed since the deletion started, whatever is still running receivesSIGKILL.
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
preStophook with asleepaction pauses the container for a few seconds beforeSIGTERMis delivered, without needing a shell or a script in the image. Thesleepaction for lifecycle hooks is a relatively recent Kubernetes feature (beta in 1.30, and stable in later releases), so on an older cluster use anexechook that runssleepfrom a shell if the image has one. -
The readiness delay inside the application, the
SHUTDOWN_READINESS_DELAY_MSsetting. This works on every platform, including those with nopreStophook, 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 |
|---|---|---|
|
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 |
Drain timeout ( |
25 s, measured from the signal |
After this, |
Closing resources |
a few seconds |
Pool closing is normally quick once the requests are gone. |
Hard exit ( |
35 s, measured from the signal |
Process-level backstop. |
|
45 s |
Must exceed |
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
timeoutSecondsduring 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;
});
});
Source: Node.js: Test runner
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 |
Raised as an |
The callback receives the |
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 |
Closing the server |
Same |
Same. There is still no |
Path syntax for the health routes |
Plain strings such as |
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()andserver.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 plainclose()no longer hangs because of idle keep-alive sockets. The explicitcloseIdleConnections()in the module above stays for Node.js 18. -
Node.js 18 enabled
requestTimeoutby default (300 seconds) and raised the defaultheadersTimeoutto 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
grpcprobes (stable in 1.27), a nativesleepaction 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 |
Bugl, chapter 3; Monteiro 2014 |
Handle |
|
Bugl, chapter 3 |
On Express 5 the callback receives the error (for example |
Running the application in production under |
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 |
No health endpoints: the only monitor is whether the port answers, or a hand-made |
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 |
Community tutorials of the Express 4 era |
|
Hosting on Heroku free dynos, with a 30-second |
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. |
|
No |
Register the handlers and use the exec form |
A rolling deployment produces a burst of |
The listener closes at once on |
Add a |
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 |
Intermittent |
|
Set |
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. |
|
Raise the grace period above |
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 |
The flag is set but the readiness handler never looks at it, or the check results are cached. |
Return |
The test process hangs after the last test. |
The server is closed but |
Call |
|
The handler is treated as error recovery. |
Log, start the shutdown with a failure code and let the platform start a clean process. |
References
-
Kubernetes: Configure liveness, readiness and startup probes
-
Publisher pages of the consulted books are listed in the section bibliography.
-
Related pages: Error Handling, Deployment & Containers, Performance Best Practices, Behind Proxies, Testing, Process, Signals and Exit Codes, Production Deployment, Kubernetes: Health probes and lifecycle