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
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.headersSentand callnext(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.