Async Handlers and Error Propagation in Express 5

Intermediate
13 min

Async Handlers and Error Propagation in Express 5

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.

What changed in Express 5

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:

javascript
// 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.

Operational errors versus bugs

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:

javascript
// 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.

The complete error pipeline

Order matters: routes, then a 404 catcher, then the error handler as the very last app.use.

javascript
// 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.

Process-level safety nets

Errors thrown outside any request (a background job, a promise nobody awaited) never reach Express. Register handlers once in server.js:

javascript
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".

Common mistakes

  • Throwing plain strings (throw "not found"). Throw Error subclasses so stack, name and isOperational exist.
  • Dropping the fourth parameter on the error handler, which turns it into a regular middleware that never runs for errors.
Quick Quiz
Question 1 of 3

In Express 5, what happens when an `async` route handler throws?

Key Takeaways

  • Express 5 forwards rejected promises from async handlers and middleware to the error middleware; wrapper libraries are no longer needed.
  • Callback and event-based code still needs an explicit next(err).
  • Model expected failures with an AppError class carrying statusCode and isOperational; treat everything else as a 500 and log it.
  • Mount a 404 handler after routes and a four-argument error handler last; normalise library errors there.
  • Handle 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.

Async Handlers and Error Propagation in Express 5 - Express.js | CodeYourCraft | CodeYourCraft