Skip to content
5 min read

Express middleware order and error handling

Middleware runs in the order you register it, and most Express bugs are that sentence ignored. Correct ordering, async errors, and one handler that owns responses.

On this page
  1. Order is the API
  2. Async errors do not reach Express by themselves
  3. One handler owns every error response
  4. 404 is not an error, and goes in the same place
  5. Four things worth knowing before they bite
  6. Three things to get right

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

ts
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 last

Two 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:

ts
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:

src/shared/async.tsts
import type { RequestHandler } from "express";

export const wrap =
  (fn: RequestHandler): RequestHandler =>
  (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
ts
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.

src/shared/errors.tsts
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

ts
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.

Frequently asked

What order should Express middleware be in?

Roughly: request id and logging, security headers, CORS, body parsing, rate limiting, authentication, then routes, then a 404 handler, then the error handler last. The guiding rule is that anything a route depends on must be registered before that route, and anything that reports on the outcome must come after.

Why is my Express error handler not being called?

Two usual causes. It is not declared with four arguments — Express identifies error middleware by arity, so (err, req, res, next) is required. Or it is registered before the routes, in which case the routes are mounted after it and their errors pass it by. It must be the last app.use call.

How do I handle async errors in Express 4?

A rejected promise inside an async handler is not caught by Express 4; the request hangs until it times out. Either wrap every async handler in a helper that forwards rejections to next(), or wrap the body in try/catch and call next(err). Express 5 forwards rejected promises automatically.

Should the error response include the stack trace?

Never in production. Return a stable shape with a status, a safe message and a correlation id, and log the stack server-side against that id. A stack trace in a response tells an attacker about your dependencies and file layout, and tells the user nothing they can act on.

Found this useful?Share on XShare on LinkedIn

Related reading