Almost every real handler awaits a database, an HTTP call or a file. In Express 4 a rejected promise inside such a handler was silently lost and the request hung until the client timed out. Express 5 fixes this at the router level. After this lesson you will know which errors reach your error middleware automatically, which still need next(err), how to model expected failures with a custom error class, and how to protect the process from crashes you did not anticipate.
The Express 5 router inspects the value returned by a handler. If it is a promise that rejects, the rejection is passed to next(err) for you:
// Express 4: this rejection was never caught; the request hung
app.get("/users/:id", async (req, res) => {
const user = await User.findById(req.params.id); // throws on bad id
res.json(user);
});The same code in Express 5 lands in your error middleware with the CastError. Wrapper packages such as express-async-handler and hand-written catchAsync helpers are unnecessary now, and try/catch blocks that only re-threw to next can be deleted.
Two limits remain. First, only rejections of the returned promise are captured, so mark the handler async or return the promise; a promise created and forgotten inside a synchronous function is still an unhandled rejection. Second, callback-style APIs (fs.readFile(path, cb), event emitters, setTimeout) bypass promises entirely: inside the callback you must still write if (err) return next(err);, or wrap the call in a promise with util.promisify or the node:fs/promises API.
A missing record or an invalid token is an operational error: expected and safe to describe to the client. A TypeError from reading a property of undefined is a programmer error: unexpected, and something the client must never see in detail. A small class makes the distinction explicit:
// src/utils/app-error.js
export class AppError extends Error {
constructor(message, statusCode = 500, details) {
super(message);
this.name = this.constructor.name;
this.statusCode = statusCode;
this.details = details;
this.isOperational = true;
}
}
export class NotFoundError extends AppError {
constructor(resource = "Resource") { super(`${resource} not found`, 404); }
}Services throw these; controllers never build responses for failures.
Order matters: routes, then a 404 catcher, then the error handler as the very last app.use.
// src/app.js
app.use("/api", routes);
app.use((req, res) => {
res.status(404).json({ error: `Cannot ${req.method} ${req.originalUrl}` });
});
app.use((err, req, res, next) => {
if (res.headersSent) return next(err); // let Node close the broken response
// normalise well-known library errors into operational ones
if (err.type === "entity.parse.failed") err = new AppError("Malformed JSON body", 400);
if (err.name === "CastError") err = new AppError(`Invalid ${err.path}`, 400);
if (err.name === "JsonWebTokenError" || err.name === "TokenExpiredError") {
err = new AppError("Invalid or expired token", 401);
}
const status = err.isOperational ? err.statusCode : 500;
if (!err.isOperational) req.log?.error(err) ?? console.error(err);
res.status(status).json({
error: err.isOperational ? err.message : "Internal Server Error",
...(process.env.NODE_ENV !== "production" && { stack: err.stack }),
});
});The 404 handler is a normal two-argument middleware placed after all routes; the error handler must keep all four parameters or Express will not treat it as one. entity.parse.failed is the type express.json() assigns to invalid JSON bodies.
Errors thrown outside any request (a background job, a promise nobody awaited) never reach Express. Register handlers once in server.js:
process.on("unhandledRejection", (reason) => {
console.error("Unhandled rejection:", reason);
server.close(() => process.exit(1));
});
process.on("uncaughtException", (err) => { console.error(err); process.exit(1); });After a programmer error the process state is unknown, so log, stop accepting connections and exit; PM2 or the container runtime restarts a clean instance. Never swallow these events to "keep the server up".
throw "not found"). Throw Error subclasses so stack, name and isOperational exist.In Express 5, what happens when an `async` route handler throws?
async handlers and middleware to the error middleware; wrapper libraries are no longer needed.next(err).AppError class carrying statusCode and isOperational; treat everything else as a 500 and log it.unhandledRejection and uncaughtException by logging and exiting, and let the process manager restart.Next lesson: Logging with Morgan and Pino — record every request and error in a format you can search, from coloured dev output to structured JSON in production.