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

Middleware, error boundaries, and graceful shutdown

Middleware is an ordered pipeline ending in one 4-arg error boundary that maps typed errors to status codes and a safe body. Async throws need forwarding in Express 4, and SIGTERM must drain in-flight requests via server.close() before exit.

NODE Middle ◷ 18 min
Level
FoundationsJuniorMiddleSenior

Every deploy, the dashboards lit up with a burst of 502s — a few hundred failed requests, then quiet. The app itself was healthy; the rollout just killed pods mid-request. Kubernetes sent SIGTERM, the old process died instantly, and every in-flight request — including a 9-second report export — was severed at the socket, surfacing to the load balancer as a 502. The fix wasn’t a bigger cluster or a retry library. It was eight lines: catch SIGTERM, call server.close() to stop accepting new connections while letting open ones finish, then exit. The outage was never about capacity. It was about a process that didn’t know how to say goodbye.

This lesson covers three disciplines that live in the same codebase and fail together: middleware ordering, error boundaries, and graceful shutdown — get any one wrong and users see silent bugs or 502 bursts.

Middleware is an ordered pipeline

Why does it matter that middleware runs in order? Because a single misplaced app.use() call can silently strip authentication from a route or expose req.body as undefined in production — with no error thrown. An HTTP framework like Express is, at its core, an array of functions called in the order you registered them. Each (req, res, next) runs, does its slice of work, and calls next() to hand control to the next one — or calls next(err) to skip everything and jump straight to the error handler. The request flows through the array; the response flows back. Because it’s an ordered array, registration order is behaviour, not style: a body parser registered after a route means the route sees req.body as undefined; an auth check registered after a protected route means the route runs for unauthenticated users.

import express from "express";
const app = express();

app.use(logger);              // 1. cross-cutting: runs for every request
app.use(express.json());      // 2. body parser — MUST precede routes that read req.body
app.use(authenticate);        // 3. populates req.user (or next(err) on bad token)

app.get("/orders/:id", requireAuth, getOrder); // 4. routes, after their prerequisites

app.use(notFound);            // 5. 404 — only reached if no route matched
app.use(errorHandler);        // 6. error boundary — 4 args, registered LAST

The mental model is a funnel: cross-cutting concerns first (logging, request id, CORS), then parsing, then authentication and authorization, then validation, then the route. The two terminal handlers — a 404 catch-all and the error boundary — go after every route, because middleware after a res.send() (or after a matched route ends the response) never runs. Composition is the payoff: logging, auth, rate-limiting, and validation each live in one reusable function instead of being copy-pasted into every handler.

One centralized error boundary

Express recognizes one special shape: a middleware with four arguments, (err, req, res, next). The arity is the signal — Express counts fn.length === 4 to classify it as the error handler, so dropping next by accident turns your error handler into a silently-skipped normal middleware. You want exactly one of these, registered last, and it does three jobs: map a typed error to a status code, send a safe body to the client, and log the full error for operators.

function errorHandler(err, req, res, next) {
  if (res.headersSent) return next(err); // response started — let Express close it

  const status = err.status ?? statusFor(err); // ValidationError→400, NotFoundError→404
  logger.error({ err, reqId: req.id }, "request failed"); // full stack + cause for ops

  res.status(status).json({
    error: { code: err.code ?? "INTERNAL", message: status < 500 ? err.message : "Internal error" },
  }); // 5xx hides internals; 4xx can echo the validation message
}

The crucial separation is client view vs operator view. The client gets a stable shape — a code and a message — and for 5xx that message is a generic "Internal error", never a stack trace, a SQL fragment, or a file path. Leaking a stack to the client hands an attacker your dependency versions and internal structure. Operators get the opposite: the whole Error object with its stack and cause chain in the logs. One place decides the mapping, so a new error type means one edit, not a grep across every handler.

MiddlewareMust come beforeFailure if misordered
Body parser (express.json())any route reading req.bodyreq.body is undefined
Auth / requireAuthprotected routeshandler runs for anonymous users
404 catch-allthe error handler, after all routesnever reached, or shadows real routes
Error handler (4-arg)nothing — it is LASTif first, it never runs at all
Quiz

Why must the error-handling middleware be registered LAST, after all routes and the 404 handler, and why does it need four arguments?

Async errors and validation at the boundary

Here is the Express 4 trap that produces silent, hung requests. A synchronous throw inside a handler is caught by Express and routed to the error handler. But a rejection inside an async handler is not — Express 4 never awaits your handler, so the rejected promise escapes into process as an unhandledRejection, the client’s request hangs until it times out, and your error boundary never sees it.

// Express 4: this rejection is NOT caught — request hangs, unhandledRejection fires
app.get("/users/:id", async (req, res) => {
  const user = await db.findUser(req.params.id); // rejects? escapes Express
  res.json(user);
});

// Fix A — try/catch and forward explicitly
app.get("/users/:id", async (req, res, next) => {
  try { res.json(await db.findUser(req.params.id)); }
  catch (err) { next(err); } // now it reaches the error boundary
});

// Fix B — a wrapper (or the express-async-errors package) does it for every route
const wrap = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
app.get("/users/:id", wrap(async (req, res) => res.json(await db.findUser(req.params.id))));

Express 5 fixes this at the framework level: a rejected promise returned from a handler is forwarded to next(err) automatically, so the wrapper becomes unnecessary. Until you’re on 5, wrap every async handler. The companion pattern is validation middleware (zod, joi, celebrate) that rejects malformed input at the boundary with a 400 before any handler logic runs — so your business code can assume req.body is already the right shape, and bad input becomes a clean typed ValidationError instead of a deep TypeError.

Why this works

Why does the rejection “hang” the request instead of just crashing? When the async handler rejects, no code ever calls res.send, res.json, or next for that request. Express has no timeout of its own, so the socket stays open with the client waiting. The process keeps serving other requests; only that one connection is stuck until the client or a proxy gives up (often 30–60s). That’s why it’s worse than a crash — it’s invisible in success metrics and only shows up as latency outliers and an unhandledRejection in the logs.

Quiz

On Express 4, an async route handler does `await db.query(...)` which rejects, with no try/catch and no wrapper. What happens?

Graceful shutdown on SIGTERM

Orchestrators stop a process by sending SIGTERM (Kubernetes, Docker stop, and systemd all do this). The default Node behaviour for SIGTERM is to exit immediately — dropping every in-flight request. Graceful shutdown means: stop accepting new connections, let existing requests finish, release resources, then exit.

process.on("SIGTERM", async () => {
  isReady = false; // 1. flip readiness so the LB stops routing new traffic

  server.close(async () => { // 2. stop accepting new conns; callback fires when in-flight finish
    await pool.end();        // 3. close DB pool and other resources
    process.exit(0);
  });

  setTimeout(() => process.exit(1), 10_000).unref(); // 4. hard cap — don't hang forever
});

Four points matter. First, flip the readiness probe to “not ready” before server.close(), so the load balancer drains you instead of sending requests to a port that’s about to close. Second, server.close() stops new connections and fires its callback only once existing requests complete. Third, the hard setTimeout(..., 10_000) is mandatory: HTTP keep-alive leaves idle connections open, and an idle keep-alive socket counts as a “connection” that can keep server.close() from ever firing — so you force-exit after a deadline. Fourth, the numbers: Kubernetes’ default terminationGracePeriodSeconds is 30s, after which it sends SIGKILL and you lose control — so your hard timeout (e.g. 10s) must be comfortably under that window.

Pick the best fit

On SIGTERM in a Kubernetes pod, how should an HTTP service shut down so a rolling deploy produces zero dropped requests?

Order the steps

Order the graceful-shutdown sequence after a SIGTERM arrives, so a rolling deploy drops zero requests:

  1. 1 SIGTERM arrives from the orchestrator (Kubernetes / Docker / systemd)
  2. 2 Flip the readiness probe to 'not ready' so the load balancer drains new traffic
  3. 3 Call server.close() to stop accepting new connections while in-flight requests finish
  4. 4 In server.close()'s callback, close the DB pool and other resources
  5. 5 process.exit(0) — or a hard setTimeout forces exit(1) if draining stalls
Recall before you leave
  1. 01
    In Express 4, why does a rejection inside an async route handler hang the request instead of reaching your error handler, and what are the two fixes?
  2. 02
    Walk through a correct graceful shutdown on SIGTERM, and explain why the hard timeout and the readiness flip are both required.
Recap

An HTTP framework is an ordered pipeline of (req, res, next) functions: each calls next() to advance or next(err) to jump to the error handler, so registration order is behaviour — the body parser must precede routes that read req.body, auth must precede protected routes, and the 404 catch-all plus the error boundary must come after all routes. That boundary is one middleware with exactly four arguments, (err, req, res, next) — Express classifies it by arity — and it does three things: map a typed error to a status code (ValidationError→400, NotFoundError→404, else 500), send a safe client body that hides stacks and internals on 5xx, and log the full error with its cause chain for operators. The sharp Express 4 trap is that a rejection inside an async handler is never awaited, so it escapes to unhandledRejection, hangs the request, and never reaches your boundary — fix it with try/catch + next(err) or a wrapper, while Express 5 forwards rejected promises automatically and validation middleware rejects bad input with a 400 at the edge. Finally, graceful shutdown: on SIGTERM, flip readiness to not-ready so the load balancer drains you, call server.close() to finish in-flight requests, close the DB pool, and exit — with a hard setTimeout force-exit because idle keep-alive sockets can otherwise keep the server open past Kubernetes’ 30s terminationGracePeriodSeconds and its untrappable SIGKILL. Now when you see a deploy that produces a burst of 502s, you’ll know exactly where to look: SIGTERM handler, readiness flip, and the hard timeout.

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.