Skip to main content
Node.js · lesson 12 of 20
Node.js intermediate Lesson 12 of 20

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

nodejs express docker health-checks

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

Frequently Asked Questions

Why doesn't SIGTERM just stop a Node process immediately?
It would, for a normal process — SIGTERM's default action is to terminate. But a process running as PID 1 inside a Linux container is special-cased by the kernel: default signal dispositions don't apply to PID 1 unless the process explicitly installs a handler for that signal. With no handler, a containerized Node server simply ignores SIGTERM and keeps running.
What happens if my app never handles SIGTERM and I run docker stop?
docker stop sends SIGTERM, waits for a grace period (10 seconds by default, set with -t), and if the container hasn't exited by then, sends SIGKILL — which cannot be caught or ignored. Every in-flight request still being handled when SIGKILL lands is cut off with no response at all. Running the container with docker run --init (or init: true in Compose) swaps PID 1 for a tiny init process (tini) that forwards signals and reaps zombies, which restores the normal default-terminates behavior for an app with no handler of its own — but that's "exit immediately," not "drain gracefully"; it doesn't add the readiness-first, wait-for-in-flight-requests behavior this lesson builds by hand.
What's the difference between /healthz and /readyz?
A liveness check (/healthz) answers "is the process alive and able to respond at all" — a failure here usually means restart the container. A readiness check (/readyz) answers "should traffic be sent to this instance right now" — a failure here means stop routing new requests without restarting anything, which is exactly the state this lesson's server enters while draining.
Why fail /readyz before calling server.close(), instead of at the same time?
A load balancer polls /readyz on an interval, not continuously, so there's always a gap between the check actually failing and the load balancer noticing. If server.close() ran at the same moment /readyz started failing, a new request arriving during that gap — before the load balancer has seen the failing check — would hit a socket that's already refusing new connections and fail outright, instead of landing on a different, healthy instance. Failing /readyz first and closing the socket a beat later buys exactly that gap: the load balancer gets a chance to see the failing check and stop sending new traffic before this instance actually stops accepting connections.
What exit code does a process killed by SIGKILL show in Docker?
137 — that's 128 plus signal number 9 (SIGKILL), Docker's convention for reporting a process terminated by a signal. A clean process.exit(0) shows exit code 0, which is how you can tell from the outside whether a container drained properly or got killed.