Node.js · lesson 12 of 20
Beginner
6Intermediate
10- 7 Structured Logging with Pino
- 8 Async Route Handlers and Errors in Express 5
- 9 Testing an HTTP API with node:test and fetch
- 10 The Same API in Fastify
- 11 JWT Auth Middleware
- 12 Graceful Shutdown and Health Checks
- 13 File Uploads with multer
- 14 A WebSocket Server with ws
- 15 OpenAPI Docs from Fastify Schemas
- 16 PostgreSQL with pg: Pools and Transactions
Advanced
4Also part of Node.js
10- + Files, Streams, and the Command Line in Node.js from JavaScript
- + JavaScript Event Loop Explained from JavaScript
- + JavaScript Modules: ESM and CommonJS from JavaScript
- + Introduction to Docker from Docker
- + Managing Docker Containers from Docker
- + Multi-Stage Builds from Docker
- + Docker Compose from Docker
- + Node.js with TypeScript from TypeScript
- + Unsafe File Uploads: secure_filename, Allowlists and Size Limits from Web Security (OWASP Top 10, fix-side)
- + Decorators in TypeScript from TypeScript
Graceful Shutdown and Health Checks
Watch Docker SIGKILL a server mid-request with no handler, then add one that fails readiness, drains the same slow request, and exits cleanly instead.
By SkillByExample · Updated
A Node server with no SIGTERM handler doesn’t shut down gracefully or abruptly when docker stop runs — it does nothing at all, until Docker gives up and sends SIGKILL.
PID 1 signal handling only shows up inside a real container, so every test below runs each server as its own docker run container on a shared network, with a separate curl-side container sending the requests:
$ docker network create node-lab
5c420de07e1aafb7a688206dbcadd1afc06bf6447cdd8b3cbb826f9205f52aa0
$ docker run -d --name curl-side --network node-lab node:26 sleep 3600
735382479a221b4b9765d0cd6cadc64dc16b216d4bc444e0299cc78cdaf58585
No Handler: SIGTERM Is Ignored, Then SIGKILL Cuts a Request Off
// server-abrupt.js
import express from "express";
const app = express();
app.get("/healthz", (req, res) => res.json({ status: "ok" }));
app.get("/slow", async (req, res) => {
console.log("slow request started");
await new Promise((resolve) => setTimeout(resolve, 3000));
console.log("slow request finished");
res.json({ done: true });
});
const server = app.listen(3000, () => console.log("listening on http://localhost:3000"));
// No SIGTERM handler: as PID 1 inside the container, Node ignores SIGTERM
// entirely without one installed — nothing happens until Docker's grace
// period expires and escalates to SIGKILL, which can't be caught or ignored
// and ends the process (and every open connection) immediately.
Start it on the network, wait for it to actually be listening (docker run -d returns as soon as the container starts, not once Node has finished loading Express and bound the port), then send a request to /slow and stop the container while that request is still in flight — docker stop -t 1 sends SIGTERM, waits its 1-second grace period, and sends SIGKILL:
$ docker run -d --name abrupt-demo --network node-lab -v "$(pwd)":/app -w /app node:26 node server-abrupt.js
74ee6f6b80f6166b6af493e7a12648321ca357ec1a8b888f7c2146a7e6576c6d
$ until docker logs abrupt-demo 2>&1 | grep -q listening; do sleep 0.2; done
$ docker exec curl-side curl -s -S -i --max-time 10 http://abrupt-demo:3000/slow &
$ sleep 0.5 && docker stop -t 1 abrupt-demo
curl: (52) Empty reply from server
abrupt-demo
$ wait
$ docker logs abrupt-demo
listening on http://localhost:3000
slow request started
$ docker inspect abrupt-demo --format '{{.State.ExitCode}}'
137
"slow request finished" never prints — the process was SIGKILLed roughly 1 second after the grace period started, while /slow’s 3-second wait was still pending. curl’s own error text only shows up with -S added alongside -s (-s alone silences it too), and what it reports is curl: (52) Empty reply from server — not a timeout and not an HTTP error status. It appears under the docker stop line above, not under the curl ... & line that started it, because the backgrounded curl only actually finishes — and only then prints that line — once SIGKILL tears the connection down, which happens while docker stop is still running. The connection was torn down with nothing written to it at all. Exit code 137 (128 + signal 9) is Docker’s way of showing a container was killed, not stopped on its own.
A Handler That Fails Readiness First, Then Drains
// server-graceful.js
import express from "express";
const app = express();
let shuttingDown = false;
app.get("/healthz", (req, res) => res.json({ status: "ok" }));
app.get("/readyz", (req, res) => {
if (shuttingDown) {
res.status(503).json({ status: "shutting down" });
return;
}
res.json({ status: "ready" });
});
app.get("/slow", async (req, res) => {
console.log("slow request started");
await new Promise((resolve) => setTimeout(resolve, 3000));
console.log("slow request finished");
res.json({ done: true });
});
const server = app.listen(3000, () => console.log("listening on http://localhost:3000"));
process.on("SIGTERM", () => {
console.log("SIGTERM received: failing readiness, then draining");
shuttingDown = true;
// Flip /readyz to 503 first so a load balancer stops sending new traffic,
// then stop accepting new connections and wait for in-flight ones to finish.
setTimeout(() => {
server.close(() => {
console.log("all connections drained, exiting");
process.exit(0);
});
}, 5000);
});
$ docker run -d --name health-demo --network node-lab -v "$(pwd)":/app -w /app node:26 node server-graceful.js
bd6a350266b56d8b63222360c3158617f58487415b9b0c6f1303daac96f7d6db
$ until docker logs health-demo 2>&1 | grep -q listening; do sleep 0.2; done
$ docker exec health-demo curl -s http://localhost:3000/healthz
{"status":"ok"}
$ docker exec health-demo curl -s http://localhost:3000/readyz
{"status":"ready"}
SIGTERM has to actually reach the server before /readyz can show anything — waiting for listening above means process.on("SIGTERM", ...) is already registered too, since it’s set up synchronously right after app.listen() is called, before that callback ever fires. The pre-close delay below is 5 seconds so the /readyz check has a wide, reliable window to land in, after docker kill and the separate docker exec curl-side curl ... round trip that follows it:
$ docker kill --signal=TERM health-demo
health-demo
$ sleep 1
$ docker exec curl-side curl -s -i -m 1 http://health-demo:3000/readyz
HTTP/1.1 503 Service Unavailable
X-Powered-By: Express
Content-Type: application/json; charset=utf-8
Content-Length: 26
ETag: W/"1a-Am3Cr49MVH4N0+JLUlHPOfqxVsw"
Date: Fri, 30 Oct 2026 05:42:48 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"status":"shutting down"}
$ docker wait health-demo
0
$ docker logs health-demo
listening on http://localhost:3000
SIGTERM received: failing readiness, then draining
all connections drained, exiting
Now repeat the same in-flight /slow request this lesson opened with, against a fresh container running the graceful server. The docker stop grace period has to cover both the 5-second pre-close delay and whatever’s left of /slow’s own 3 seconds, not just one or the other:
$ docker run -d --name graceful-demo --network node-lab -v "$(pwd)":/app -w /app node:26 node server-graceful.js
9315ebce46d9ca48c447c3d1ba48652a0c037c58a7f57b62b06fba05097652c1
$ until docker logs graceful-demo 2>&1 | grep -q listening; do sleep 0.2; done
$ docker exec curl-side curl -s -i -w '\n' --max-time 12 http://graceful-demo:3000/slow &
$ sleep 0.5 && docker stop -t 9 graceful-demo
HTTP/1.1 200 OK
X-Powered-By: Express
Content-Type: application/json; charset=utf-8
Content-Length: 13
ETag: W/"d-vfAVvdILIkmIo67j2sc7s17+9M0"
Date: Fri, 30 Oct 2026 05:43:03 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"done":true}
graceful-demo
$ wait
$ docker logs graceful-demo
listening on http://localhost:3000
slow request started
SIGTERM received: failing readiness, then draining
slow request finished
all connections drained, exiting
$ docker inspect graceful-demo --format '{{.State.ExitCode}}'
0
Same slow request, same SIGTERM, a completely different outcome: /slow gets its real 200 {"done":true} response, docker logs shows "slow request finished" this time, and the exit code is 0 — the process ended itself with process.exit(0), before Docker’s grace period ever needed to escalate to SIGKILL. The only difference from the abrupt server is this process.on("SIGTERM", ...) handler; everything else about the route is identical.
Practice
1. Add a second slow route that takes 5 seconds, send SIGTERM with a 3-second docker stop -t 3 grace period, and predict whether the graceful server still finishes it.
Solution
// server-graceful.js (added route)
import express from "express";
const app = express();
let shuttingDown = false;
app.get("/healthz", (req, res) => res.json({ status: "ok" }));
app.get("/readyz", (req, res) => {
if (shuttingDown) {
res.status(503).json({ status: "shutting down" });
return;
}
res.json({ status: "ready" });
});
app.get("/slow", async (req, res) => {
console.log("slow request started");
await new Promise((resolve) => setTimeout(resolve, 3000));
console.log("slow request finished");
res.json({ done: true });
});
app.get("/very-slow", async (req, res) => { // new
console.log("very-slow request started"); // new
await new Promise((resolve) => setTimeout(resolve, 5000)); // new
console.log("very-slow request finished"); // new
res.json({ done: true }); // new
}); // new
const server = app.listen(3000, () => console.log("listening on http://localhost:3000"));
process.on("SIGTERM", () => {
console.log("SIGTERM received: failing readiness, then draining");
shuttingDown = true;
setTimeout(() => {
server.close(() => {
console.log("all connections drained, exiting");
process.exit(0);
});
}, 5000);
});
graceful-demo already exited at the end of the main example, so this route needs a fresh container running the edited file before it can be tested:
$ docker rm -f graceful-demo
graceful-demo
$ docker run -d --name graceful-demo --network node-lab -v "$(pwd)":/app -w /app node:26 node server-graceful.js
123297c1cce5361a55ccbca4f81bc4979e014c249d6987719b4c57e1870eec1f
$ until docker logs graceful-demo 2>&1 | grep -q listening; do sleep 0.2; done
$ docker exec curl-side curl -s -S -i --max-time 10 http://graceful-demo:3000/very-slow &
$ sleep 0.5 && docker stop -t 3 graceful-demo
curl: (52) Empty reply from server
graceful-demo
$ wait
No — the graceful handler still only has however long the grace period gives it. A 3-second docker stop -t 3 grace period doesn’t even cover the 5-second pre-close delay on its own, let alone the in-flight request behind it; it gets SIGKILLed before server.close() is ever called, the exact same way the abrupt server failed earlier. “Graceful” means attempting to drain, not an unlimited amount of time to do it — the grace period has to cover the pre-close delay and the slowest request the app expects to actually finish in.
2. Remove the setTimeout delay before server.close() (close immediately on SIGTERM) and repeat the /readyz check during shutdown.
Solution
// server-graceful.js (no pre-close delay)
import express from "express";
const app = express();
let shuttingDown = false;
app.get("/healthz", (req, res) => res.json({ status: "ok" }));
app.get("/readyz", (req, res) => {
if (shuttingDown) {
res.status(503).json({ status: "shutting down" });
return;
}
res.json({ status: "ready" });
});
app.get("/slow", async (req, res) => {
console.log("slow request started");
await new Promise((resolve) => setTimeout(resolve, 3000));
console.log("slow request finished");
res.json({ done: true });
});
const server = app.listen(3000, () => console.log("listening on http://localhost:3000"));
process.on("SIGTERM", () => {
console.log("SIGTERM received, draining connections");
shuttingDown = true;
server.close(() => {
console.log("all connections drained, exiting");
process.exit(0);
});
});
health-demo from the main example already exited, so this edited handler needs its own fresh container too:
$ docker rm -f health-demo
health-demo
$ docker run -d --name health-demo --network node-lab -v "$(pwd)":/app -w /app node:26 node server-graceful.js
02a48f64782e9b82e3aacf91003f6e512c2e1367d1e2193f7118010ed8f6032f
$ until docker logs health-demo 2>&1 | grep -q listening; do sleep 0.2; done
$ docker kill --signal=TERM health-demo
health-demo
$ docker exec curl-side curl -s -S -i -m 1 http://health-demo:3000/readyz
curl: (28) Resolving timed out after 1001 milliseconds
$ docker logs health-demo
listening on http://localhost:3000
SIGTERM received, draining connections
all connections drained, exiting
Not a connection timeout — a DNS resolution timeout. With nothing in flight to drain, server.close()’s callback fires on the very next tick, so process.exit(0) runs within a millisecond or two of the SIGTERM handler starting. By the time the curl in the other container even starts resolving health-demo, the container has already exited and Docker’s embedded DNS has nothing left to answer with — the lookup itself hangs until -m 1’s deadline, rather than a GET ever reaching a socket. The entire point of the original 5-second setTimeout before server.close() was to hold that window open long enough for a readiness probe to actually see {"status":"shutting down"} before the container disappears; removing it doesn’t just change how a new connection gets refused, it removes the window entirely.
3. Send SIGTERM to the graceful server while it is idle (no requests in flight), and time how long it takes to exit.
Solution
This needs the regular handler again (Practice 2’s edit removed the delay), running in yet another fresh container, since health-demo is still the exited container from Practice 2 — the file below is identical to the main example’s server-graceful.js, nothing changed:
// server-graceful.js (regular handler again)
import express from "express";
const app = express();
let shuttingDown = false;
app.get("/healthz", (req, res) => res.json({ status: "ok" }));
app.get("/readyz", (req, res) => {
if (shuttingDown) {
res.status(503).json({ status: "shutting down" });
return;
}
res.json({ status: "ready" });
});
app.get("/slow", async (req, res) => {
console.log("slow request started");
await new Promise((resolve) => setTimeout(resolve, 3000));
console.log("slow request finished");
res.json({ done: true });
});
const server = app.listen(3000, () => console.log("listening on http://localhost:3000"));
process.on("SIGTERM", () => {
console.log("SIGTERM received: failing readiness, then draining");
shuttingDown = true;
setTimeout(() => {
server.close(() => {
console.log("all connections drained, exiting");
process.exit(0);
});
}, 5000);
});
$ docker rm -f health-demo
health-demo
$ docker run -d --name health-demo --network node-lab -v "$(pwd)":/app -w /app node:26 node server-graceful.js
77faff3c08e7e80d567cce6d3af91e6ce5bb64c1fe784f45d425b1ada149400b
$ until docker logs health-demo 2>&1 | grep -q listening; do sleep 0.2; done
$ date +%s.%N
1793339023.555437600
$ docker kill --signal=TERM health-demo
health-demo
$ docker wait health-demo
0
$ date +%s.%N
1793339030.197058600
The two timestamps above are a little over six seconds apart — the setTimeout(..., 5000) delay before server.close() runs regardless of whether anything is actually in flight to drain, plus some real overhead in docker kill itself actually delivering the signal and in docker wait reporting back. An idle server still pays the full pre-close delay; it just has nothing to wait for once close() is called, so the exit code 0 comes back as soon as that delay and the now-trivial drain are done. The exact figure drifts a little run to run, and never drops anywhere close to the 5-second delay alone, because docker kill and docker wait each add real overhead of their own on top of it.
4. Add a hard upper bound: if draining takes longer than 10 seconds, force-exit anyway with process.exit(1) instead of waiting forever.
Solution
// server-graceful.js (added a stuck route and a force-exit timeout)
import express from "express";
const app = express();
let shuttingDown = false;
app.get("/healthz", (req, res) => res.json({ status: "ok" }));
app.get("/readyz", (req, res) => {
if (shuttingDown) {
res.status(503).json({ status: "shutting down" });
return;
}
res.json({ status: "ready" });
});
app.get("/slow", async (req, res) => {
console.log("slow request started");
await new Promise((resolve) => setTimeout(resolve, 3000));
console.log("slow request finished");
res.json({ done: true });
});
app.get("/stuck", async (req, res) => { // new
console.log("stuck request started"); // new
await new Promise(() => {}); // new — never resolves, simulates a dead upstream call
}); // new
const server = app.listen(3000, () => console.log("listening on http://localhost:3000"));
process.on("SIGTERM", () => {
console.log("SIGTERM received: failing readiness, then draining");
shuttingDown = true;
const forceExit = setTimeout(() => { // new
console.log("drain timeout exceeded, forcing exit"); // new
process.exit(1); // new
}, 10000); // new
setTimeout(() => {
server.close(() => {
clearTimeout(forceExit); // new
console.log("all connections drained, exiting");
process.exit(0);
});
}, 5000);
});
graceful-demo already exited at the end of Practice 1, so this needs a fresh container running the edited file again:
$ docker rm -f graceful-demo
graceful-demo
$ docker run -d --name graceful-demo --network node-lab -v "$(pwd)":/app -w /app node:26 node server-graceful.js
93293e0ffc27b09d0eacd61e8a8d33be9fe68a32ee22ecf30c4f9bb736687203
$ until docker logs graceful-demo 2>&1 | grep -q listening; do sleep 0.2; done
$ docker exec curl-side curl -s -S -i --max-time 15 http://graceful-demo:3000/stuck &
$ sleep 0.5 && docker stop -t 15 graceful-demo
curl: (52) Empty reply from server
graceful-demo
$ wait
$ docker logs graceful-demo
listening on http://localhost:3000
stuck request started
SIGTERM received: failing readiness, then draining
drain timeout exceeded, forcing exit
$ docker inspect graceful-demo --format '{{.State.ExitCode}}'
1
A connection that somehow never finishes (a stuck upstream call, a client that opened a connection and went silent) would otherwise leave server.close()’s callback waiting forever, since close() only calls back once every connection has ended on its own — "all connections drained, exiting" never prints here. Ten seconds after SIGTERM, the independent forceExit timer fires instead, logs "drain timeout exceeded, forcing exit", and calls process.exit(1) — a different exit code from the clean path’s 0, so an orchestrator (or docker inspect) can tell “drained in time” apart from “force-killed by its own code” after the fact. clearTimeout(forceExit) on the normal path only matters when the clean close actually wins that race.
Clean up the containers and network this lesson created, since nothing above removes them automatically:
$ docker rm -f abrupt-demo health-demo graceful-demo curl-side
abrupt-demo
health-demo
graceful-demo
curl-side
$ docker network rm node-lab
node-lab