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.
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 LASTThe 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.
| Middleware | Must come before | Failure if misordered |
|---|---|---|
Body parser (express.json()) | any route reading req.body | req.body is undefined |
Auth / requireAuth | protected routes | handler runs for anonymous users |
| 404 catch-all | the error handler, after all routes | never reached, or shadows real routes |
| Error handler (4-arg) | nothing — it is LAST | if first, it never runs at all |
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.
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.
On SIGTERM in a Kubernetes pod, how should an HTTP service shut down so a rolling deploy produces zero dropped requests?
Order the graceful-shutdown sequence after a SIGTERM arrives, so a rolling deploy drops zero requests:
- 1 SIGTERM arrives from the orchestrator (Kubernetes / Docker / systemd)
- 2 Flip the readiness probe to 'not ready' so the load balancer drains new traffic
- 3 Call server.close() to stop accepting new connections while in-flight requests finish
- 4 In server.close()'s callback, close the DB pool and other resources
- 5 process.exit(0) — or a hard setTimeout forces exit(1) if draining stalls
- 01In 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?
- 02Walk through a correct graceful shutdown on SIGTERM, and explain why the hard timeout and the readiness flip are both required.
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.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.