open atlas
↑ Back to track
Node.js, zero to senior NODE · 01 · 05

Process lifecycle and graceful shutdown

A process exits when the event loop drains or you force it; the senior skill is dying cleanly — catch SIGTERM, flip readiness off, stop accepting connections, drain in-flight work under a hard timeout, then exit, so deploys cost zero 502s.

NODE Senior ◷ 18 min
Level
FoundationsJuniorMiddleSenior

Every deploy, your dashboards light up with a tidy burst of 502s and ECONNRESET for about three seconds, then go green again. The error budget shrugs it off, so nobody fixes it — until a payments rollout drops an in-flight POST /charge mid-write and a customer gets double-billed because the retry landed on a fresh pod. The service was healthy. The deploy was clean. What killed those requests was the old pod being told to stop and not knowing how to die politely: Kubernetes sent SIGTERM, your Node process had no handler, the orchestrator waited out the grace period, and then SIGKILL (137) tore the process apart with sockets still open. Graceful shutdown is the difference between a deploy nobody notices and a deploy that loses money.

The lifecycle: start, run the loop, exit

A Node process has three phases. It starts (parse, load modules, run top-level code), it runs — the event loop processes timers, I/O, and microtasks for as long as there is ref’d work to do — and it exits. The clean path is the one most people never think about: when the event loop has no more referenced work — no open server, no pending timer, no in-flight socket — it simply returns and the process exits 0 on its own. You don’t call anything. An idle HTTP server keeps the loop alive precisely because the listening socket is a ref’d handle; server.close() unrefs it, and once the last connection finishes, the loop empties and the process leaves naturally. This drain-to-exit behavior is the foundation of graceful shutdown: you don’t kill the process, you let it run out of work.

The exit code is the contract with whatever supervises you. 0 means success; any non-zero is failure. Signal-terminated processes report 128 + N: a SIGKILL (signal 9) shows 137, a SIGTERM (15) that you didn’t handle shows 143, and Ctrl-C / SIGINT (2) shows 130. When you see 137 in your orchestrator’s pod events, that is not a crash in your code — it is the kernel reporting “I had to use the hammer,” i.e. your grace period expired and something sent SIGKILL.

// The two exit hooks, and the trap that makes one of them useless
process.on("exit", (code) => {
  // SYNCHRONOUS ONLY. The loop is already gone.
  console.log(`exiting with ${code}`);
  setTimeout(() => console.log("never runs"), 0); // dropped — no more loop
});

process.on("beforeExit", () => {
  // Fires when the loop EMPTIES naturally. Can schedule async work to keep alive.
  // Does NOT fire on process.exit() or an uncaught fatal.
});

The 'exit' event is the sharpest gotcha here: its handler runs synchronously only, because by the time it fires the event loop has already stopped. Any setTimeout, fs.writeFile, or await you schedule inside it is silently dropped. 'exit' is for synchronous last-rites (write a flag, free a sync resource) — never for flushing a network buffer. 'beforeExit', by contrast, fires when the loop drains on its own and can schedule more async work (it’s how some libraries auto-flush), but it does not fire on an explicit process.exit() or a fatal error. Two events, two completely different trigger conditions — confusing them is how cleanup quietly never happens.

Signals: the polite request and the unstoppable one

Orchestrators don’t reach into your code; they send POSIX signals. SIGTERM is the universal “please stop” — it is what Kubernetes sends to a terminating pod, what systemd sends on stop, and what docker stop sends. SIGINT is the terminal interrupt, what Ctrl-C raises in the foreground. Both are catchable: you register process.on("SIGTERM", handler) to intercept the request and run your own shutdown instead of the default (immediate termination). Treat SIGTERM and SIGINT identically — the orchestrator and the developer are asking for the same thing.

Two signals you cannot touch: SIGKILL (9) and SIGSTOP. The kernel handles these directly and never delivers them to your process — there is no handler, no cleanup, no flush. This asymmetry is the entire reason graceful shutdown has a deadline: you get a polite SIGTERM with a grace window, and if you overstay it the orchestrator escalates to the unstoppable SIGKILL. Your job is to finish before the hammer comes down.

const onSignal = (sig) => {
  console.log(`received ${sig}, starting graceful shutdown`);
  shutdown();
};
process.on("SIGTERM", onSignal); // k8s / docker stop / systemd
process.on("SIGINT", onSignal);  // Ctrl-C in dev
// SIGKILL and SIGSTOP cannot be intercepted — never reach here.

The process.exit() trap, and the shutdown that does it right

The most tempting wrong answer is process.exit(0) inside the SIGTERM handler. It feels decisive, and it is a data-loss bug. process.exit() terminates immediately and synchronously — it does not wait for the event loop to drain. Any buffered stdout write, any pending fs write, any in-flight database COMMIT that hasn’t returned is truncated the instant you call it. A log line you thought you wrote vanishes; a transaction that was one round-trip from durable is abandoned. The whole point of graceful shutdown is to let the loop empty naturally; calling process.exit() mid-flight is the opposite of that.

The correct pattern flips a sequence of switches and then lets the process die on its own, with a hard timeout as the only forced exit:

let shuttingDown = false;
let ready = true; // health/readiness probe reads this

app.get("/readyz", (_req, res) => res.status(ready ? 200 : 503).end());

async function shutdown() {
  if (shuttingDown) return; // idempotent: a second SIGTERM must not re-enter
  shuttingDown = true;

  // (a) Fail readiness so the LB / k8s stops routing NEW traffic to us.
  ready = false;

  // (e) Hard deadline: if drain hangs, force-exit BELOW the grace period.
  const forceTimer = setTimeout(() => {
    console.error("drain timed out, forcing exit");
    process.exit(1); // 137-style escalation, but on OUR terms with a log line
  }, 10_000);
  forceTimer.unref(); // don't let the timer itself keep the loop alive

  try {
    // (b) Stop accepting NEW connections; let in-flight requests finish.
    await new Promise((resolve, reject) =>
      server.close((err) => (err ? reject(err) : resolve()))
    );
    // (c)+(d) In-flight work has drained; now close downstream resources.
    await Promise.all([pool.end(), broker.close(), logger.flush()]);
    clearTimeout(forceTimer);
    process.exit(0); // clean: everything drained and closed
  } catch (err) {
    console.error("error during shutdown", err);
    process.exit(1);
  }
}

The ordering is load-bearing. Flip readiness first so the load balancer deregisters you before you stop accepting — otherwise it keeps sending requests to a socket you’re about to close. Then server.close(), which stops new connections while letting existing ones complete (it does not kill live requests). Only after in-flight work drains do you close the DB pool and broker — close them too early and the requests still finishing will hit a dead connection. The setTimeout is the safety valve: it must fire below the orchestrator’s grace period so you exit on your own terms with a log line, rather than being SIGKILLed silently.

Why this works

There is one Docker trap that silently defeats all of the above: PID 1. When your Dockerfile ends with CMD ["node", "server.js"], Node runs as process ID 1 — the init process. The kernel gives PID 1 special treatment: it does not install default signal handlers for it. So if your Node process is PID 1 and has no explicit SIGTERM handler, the signal is simply ignored. docker stop sends SIGTERM, nothing happens, Docker waits the full 10-second grace period, then sends SIGKILL (137). You’ll swear your handler “doesn’t work” when the real issue is that without an explicit handler PID 1 never had the default one. Fixes: run with docker run --init (or init: true in compose) to get tini as PID 1 forwarding signals to Node, use a real init like tini in your image, or — since you’re writing handlers anyway — just ensure your explicit process.on("SIGTERM") is registered, which works even at PID 1.

Pick the best fit

A Node HTTP service behind Kubernetes (terminationGracePeriodSeconds: 30) needs to stop dropping in-flight requests on every rolling deploy. What does the SIGTERM handler do?

Quiz

A pod's events show the container exited with code 137 after a deploy. What does that tell you?

Recall before you leave
  1. 01
    Why must you flip the readiness probe to 503 before calling server.close(), and what breaks if you reverse the order or skip it?
  2. 02
    What exactly is the 'process.exit() trap', and how does the correct shutdown avoid it while still guaranteeing termination?
Recap

A Node process runs as long as the event loop has ref’d work and exits — code 0 — the moment that work drains, which is the mechanism graceful shutdown exploits: you don’t kill the process, you remove its work and let it leave. Exit codes are the supervisor contract (0 success, non-zero failure, 128+N for signals — 137=SIGKILL, 143=SIGTERM, 130=SIGINT), and the two exit hooks differ sharply: 'exit' runs synchronously only (async scheduled there is dropped) while 'beforeExit' fires on a natural drain and not on process.exit(). Orchestrators ask you to stop with the catchable SIGTERM (and SIGINT for Ctrl-C); SIGKILL/SIGSTOP cannot be intercepted, which is why the polite request comes with a deadline. The handler must flip readiness to 503 first (so the LB deregisters and the new-traffic race closes), server.close() to drain in-flight requests, then close pools, brokers, and logs, and exit — never process.exit() mid-flight, because that truncates buffered writes and in-flight commits. Guard the whole thing with a hard setTimeout force-exit set below the grace period (k8s default 30s, docker stop 10s), and remember the Docker PID-1 trap: without an explicit handler, signals to PID 1 are ignored, so docker stop waits out the grace period then SIGKILLs — fix it with --init/tini or by registering the handler you already wrote.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 5 done

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.