Error-handling Middleware

intermediate express middleware errors

Error-handling middleware is just regular middleware with one quirk: it takes four arguments instead of three. Express specifically looks at the function’s arity (number of parameters) to decide “ah, this one handles errors.” Drop one of those four args and Express treats it as normal middleware. Weird but true.

In simple language: it’s the safety net at the bottom of our middleware stack. Anything that calls next(err) or throws inside an async route ends up here.

The signature

function errorHandler(err, req, res, next) {
  // err is whatever was passed to next(err) or thrown
  console.error(err);
  res.status(err.status || 500).json({ error: err.message });
}

Four params, in that exact order. Even if we don’t use next, we must declare it — otherwise Express sees 3 params and skips this for errors entirely.

Where it lives

app.use(helmet())
app.use(express.json())
app.get("/users", ...)
app.post("/users", ...)
404 handler (no route matched)
errorHandler(err, req, res, next)
must be LAST

If we put it before routes, it never gets triggered — Express has nothing to error on yet.

Triggering it

Three ways an error reaches our handler:

// 1. Explicit next(err)
app.get("/users/:id", (req, res, next) => {
  if (!isValidId(req.params.id)) {
    return next(new Error("Invalid user ID"));
  }
  // ...
});

// 2. Throw inside a sync handler — Express catches it
app.get("/boom", (req, res) => {
  throw new Error("Something exploded");
});

// 3. Async handler — must call next(err) or use a wrapper
app.get("/users/:id", async (req, res, next) => {
  try {
    const user = await db.findUser(req.params.id);
    res.json(user);
  } catch (err) {
    next(err);   // throw won't work here, we must call next
  }
});

Express 5 (in beta as of 2026) automatically catches async errors. Until we’re on 5, the try/catch (or an asyncHandler wrapper) is mandatory.

A realistic error handler

class AppError extends Error {
  constructor(message, status = 500) {
    super(message);
    this.status = status;
  }
}

// 404 — no route matched
app.use((req, res, next) => {
  next(new AppError(`Route ${req.path} not found`, 404));
});

// The actual error handler — last middleware in the chain
app.use((err, req, res, next) => {
  const status = err.status || 500;
  const message = status === 500 && process.env.NODE_ENV === "production"
    ? "Internal server error"
    : err.message;

  // Log everything server-side
  req.log?.error({ err, path: req.path }, "request failed");

  res.status(status).json({
    error: message,
    ...(process.env.NODE_ENV !== "production" && { stack: err.stack }),
  });
});

Notice: we hide stack traces and 500 messages in production. Leaking err.message to clients is how attackers learn we use Postgres on port 5432.

Async wrapper trick

Wrapping every route in try/catch gets tedious. A 3-line helper fixes it:

const asyncHandler = (fn) => (req, res, next) =>
  Promise.resolve(fn(req, res, next)).catch(next);

app.get("/users/:id", asyncHandler(async (req, res) => {
  const user = await db.findUser(req.params.id);
  if (!user) throw new AppError("User not found", 404);
  res.json(user);
}));

The express-async-errors package monkey-patches this globally if we don’t want a wrapper.

Gotchas

  • Already sent the response? Express won’t override headers — check res.headersSent and call next(err) to let the default handler close the socket.
  • Multiple error handlers are allowed — Express runs them in order, and we can call next(err) to pass to the next one.
  • Don’t console.log(err) in production — use a real logger so errors are searchable.