Express's execution model is one sentence long. Middleware runs in the order you registered it, and each one decides whether to pass control on.
Almost every confusing Express bug is that sentence, ignored.
Order is the API
app.use(requestId); // 1. so every later log line can be correlated
app.use(pinoHttp({ logger })); // 2. logs the request, including ones rejected below
app.use(helmet()); // 3. security headers before anything can respond
app.use(cors({ origin: allowed })); // 4. before routes, so preflight is answered
app.use(express.json({ limit: "1mb" })); // 5. before any route reading req.body
app.use(rateLimit({ windowMs: 60_000, max: 120 })); // 6. cheap rejection before auth work
app.use(authenticate); // 7. populates req.user for the routes
app.use("/api/leads", leadRoutes); // 8. the routes themselves
app.use(notFound); // 9. nothing matched
app.use(errorHandler); // 10. four arguments, always lastTwo rules generate that entire list. Anything a route depends on is registered before the routes. Anything that reports on the outcome is registered after them.
The failures are correspondingly mechanical, which is the good news. A body parser registered after a router gives you req.body === undefined in exactly the routes that were mounted first, and nowhere else, which is a genuinely confusing afternoon. A rate limiter after authentication means unauthenticated floods still pay for a token lookup each. CORS after the routes means the preflight OPTIONS never gets its headers, and the browser then reports an error that has nothing to do with the actual problem.
Async errors do not reach Express by themselves
In Express 4, this hangs:
app.get("/leads/:id", async (req, res) => {
const lead = await service.byId(req.params.id); // rejects → nobody catches
res.json(lead);
});The promise rejects, nothing calls next, no response is sent, and the request sits there until the client or a proxy gives up. The log shows nothing useful, because nothing threw synchronously.
One helper, applied without exception:
import type { RequestHandler } from "express";
export const wrap =
(fn: RequestHandler): RequestHandler =>
(req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);app.get("/leads/:id", wrap(async (req, res) => {
res.json(await service.byId(req.params.id));
}));Express 5 forwards rejected promises to the error handler on its own. Until you are on it the rule is absolute: every async handler is wrapped, no exceptions, because the one that is not will be the one that hangs in production at a bad hour.
One handler owns every error response
Error middleware is identified by arity. Four parameters, or Express treats it as ordinary middleware and never calls it with an error.
import type { ErrorRequestHandler } from "express";
import { ZodError } from "zod";
export class AppError extends Error {
constructor(message: string, readonly status = 400, readonly code = "bad_request") {
super(message);
}
}
export const errorHandler: ErrorRequestHandler = (err, req, res, _next) => {
const id = req.id;
if (err instanceof ZodError) {
return res.status(422).json({ code: "validation_failed", fields: err.flatten().fieldErrors, id });
}
if (err instanceof AppError) {
return res.status(err.status).json({ code: err.code, message: err.message, id });
}
// Unknown: log everything, return nothing specific.
req.log.error({ err, id }, "unhandled error");
res.status(500).json({ code: "internal_error", message: "Something went wrong", id });
};Three properties make this worth the file. The response shape is identical whichever route failed, so clients handle errors once rather than per endpoint. Unexpected errors never leak a stack, a query or a file path. And the correlation id in the response is the same id in the logs, which turns "it broke this morning" into a single log query instead of a conversation.
Note the unused _next. Delete it and the arity changes and the handler silently stops being an error handler. That one is worth an actual comment in a real codebase, because it looks like dead code to everyone who reads it next.
404 is not an error, and goes in the same place
export const notFound: RequestHandler = (req, res) => {
res.status(404).json({ code: "not_found", path: req.path, id: req.id });
};Registered after the routes and before the error handler. Without it an unmatched path falls through to Express's default HTML page, which is jarring in an API and leaks the framework in the body.
Four things worth knowing before they bite
res.json() twice throws. It is usually a handler that responds and then calls next(err), or a catch that responds after the try already did. Return after responding, every time, and the whole class disappears.
Error middleware cannot rescue a sent response. If the headers are already out the handler runs and can do nothing about it, so check res.headersSent and delegate to Express's default when it is true.
Routers nest, and so does order. Middleware registered inside a router applies only to that router, which is the correct way to scope authentication to one feature rather than bolting it on globally and carving out exceptions.
And a middleware that never calls next() is a hang rather than an error. Every conditional path through a middleware must either respond or pass control. This is the single most common cause of a request that produces no log line and no response, and it is invisible in code review because the missing branch is the one nobody wrote.
Three things to get right
Express's model is small enough to hold entirely in your head. Registration order is execution order. Async rejections need forwarding. The error handler takes four arguments and comes last.
Get those three right and the category of bug that makes Express feel unpredictable — undefined bodies, hanging requests, errors that vanish without a trace — largely stops happening.
What remains is ordinary application code, which is all you wanted from the framework in the first place.