Error handling and the async trap
How Express 5's four-argument error middleware works, why route order matters for the 404 catch-all, and the async trap Express 4 developers had to work around.

Every framework needs one place where an unexpected failure ends up, and Express's answer is a special kind of middleware you'll write exactly once per app and rarely think about again. Here's Linkstash's, in full, straight from the bottom of app.ts:
app.use((_req, res) => {
res.status(404).json({ error: "NotFound" });
});
app.use((err: Error, _req: Request, res: Response, _next: NextFunction) => {
// Logged deliberately. The /boom test exercises this path, so a stack trace
// in the test output is expected, not a failure.
console.error(err);
res.status(500).json({ error: "InternalServerError" });
});Two middlewares, registered last, and the second one has four parameters instead of three. That fourth parameter is how Express tells error middleware apart from ordinary middleware, and it's not optional stylistically, it's how the framework's internal dispatch decides which functions get a shot at handling an error and which don't. A function with (req, res, next) never receives an error. A function with (err, req, res, next) only receives one.
Why these two come last
Express matches routes and middleware top to bottom, first match wins for routing, so a catch-all has to be registered after everything specific or it would swallow every request before more specific routes got a chance. The plain app.use((_req, res) => {...}) above only runs if nothing earlier in the file, no route, no other middleware, already handled the request. That's what makes it a working 404 handler: anything that reaches the bottom of the file unclaimed gets a 404 NotFound, deliberately, instead of Express's own default HTML error page.
The error handler goes after that, last of all. It only runs when something upstream calls next(err) or, in Express 5, when an async handler's promise rejects. It's not part of the normal request flow at all, it's a separate track that error propagation drops into.
The async trap, and how Express 5 closes it
Here's /boom, which exists in Linkstash for exactly one reason: to give the test suite and this lesson a reliable failure to point at.
// Exists so the Testing and Security series have a reliable 500 to demonstrate.
app.get("/boom", async () => {
throw new Error("Deliberate failure for teaching the error handler");
});That handler throws inside an async function, with nobody calling next(err) anywhere near it. In Express 4, this was the trap: an async function that throws produces a rejected promise, and Express 4's router has no idea a promise is even involved. It doesn't await anything. The rejection goes unhandled, and depending on your Node version, that either crashes the process with an "UnhandledPromiseRejection" or just vanishes, and the client's request hangs with no response at all. Neither outcome is what you want, and neither one happens by accident when you forget a try/catch, it happens by default, every single time, unless you remember to guard against it.
Express 4 developers worked around this with a wrapper, something like:
function asyncHandler(fn) {
return (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
}
app.get("/boom", asyncHandler(async () => {
throw new Error("still works, but only because of this wrapper");
}));Every route needed that wrapper, or the whole app needed a library like express-async-handler doing it globally. Miss one route and you had a silent gap.
Express 5 makes this the framework's job instead of yours. It awaits the promise an async handler returns and, on rejection, forwards the error to your error middleware automatically, no wrapper, no manual next(err), no asyncHandler. /boom throwing reaches the error handler above exactly the same way a synchronous throw would.
This only covers the handler itself
Express 5's automatic forwarding applies to the promise returned by the route handler function. If you kick off async work you don't await, an unawaited promise, a setTimeout callback, an event listener, and that code throws later, Express has no promise to watch and can't catch it. The rule holds: await what you need the result of, and let the handler's own rejection do the forwarding.
Watch the chain hit the error handler
Same visualizer as last lesson, different ending. /boom only sits behind the global express.json(), it isn't wrapped in requireAuth, so this is the chain it actually walks: the handler runs, throws, and Express 5's automatic forwarding routes the rejection to the error middleware, which is what sends the 500, not the handler itself.
express.json() passes the request through, then the handler throws. Express 5 forwards the rejection and the error middleware sends the 500.
Request arrives
There's a third way a middleware can end a chain, one you haven't seen yet: sending a response directly, without ever calling next(). That's exactly what requireAuth does on a bad token, and it's the missing piece between "hangs forever" and "throws an error." Picture the same GET /links request from lesson 5, this time with no valid Authorization header at all:
requireAuth finds no valid token. It responds with 401 and stops the chain right there, the route handler never runs at all.
Request arrives
Now you've seen every ending a chain can have: a middleware that calls next() and lets the request through, one that responds directly and stops the chain on purpose (this is what "sends" models, and it's exactly how requireAuth behaves on a bad token, no bug involved), one that forgets to do either and hangs, and a handler that throws, caught automatically and turned into a response by the error middleware.
Quick check
An async route handler awaits a database call that rejects. In Express 5, with no try/catch and no next(err) anywhere, what happens?
Errors get logged, but so far nothing has stopped a malformed request from reaching a handler in the first place. That's next: connecting a database, then validating input before it ruins your day right after. If you want the full picture of what to test once error paths like /boom exist, why write tests picks up right where this leaves off.

Written by
Rhythm Bhiwani
Engineer and relentless builder, happiest reverse-engineering hard problems until they click.
Enjoyed this?
Tap the heart to leave some love.
Be the first to react
Comments
Join the conversation.
Loading comments…


