Error Handling
In Phase 5 every handler sprinkled its own
res.status(404).json({ error: 'Not found' }) and its own try/catch. Each route reinvented "what
does an error look like to the client?" - and they didn't all agree.
The better way is an idea you already know: errors are routed to a special piece of middleware. You don't handle errors where they happen - you hand them off, and one function at the end of the chain decides what the client sees.
The mental model: an error is routed to a special door
📝 Remember the hallway of doors from Phase 3? Normal middleware has the shape
(req, res, next). Express has one more kind of door - an error handler - with a four-argument shape:
(err, req, res, next). That extra first parameter is the whole signal: Express counts your function's
parameters, and four means "this is the error door," skipped during normal traffic.
A request reaches the error door two ways:
- You call
next(err)with an argument - Express stops the normal chain and jumps to the error handler. - You throw in synchronous code - Express catches the throw and does the same jump.
So the rule: anywhere something goes wrong, don't respond - call next(err) (or throw). One handler,
one consistent error response, everywhere.
const = ;
const = ;
;
;
What just happened: the handler doesn't build a 404 itself - it creates an Error and passes it to
next(). Because next received an argument, Express abandons the normal chain and looks for an
error-handling middleware. The handler's job ends at "something is wrong, here's what."
The error-handling middleware (and a custom error that carries its status)
The error door must be registered last, after every route, so it catches whatever gets sent its
way. A plain new Error('Task not found') has a message but no notion of "this should be a 404" - fix
that with a tiny custom error class that carries a status code.
// ...routes go here...
// LAST: the one error handler for the whole app
;
What just happened: AppError is a normal Error with one extra field, statusCode. A route can
now throw new AppError('Task not found', 404) and the handler reads err.statusCode to set the
response code. Anything without one - a real bug, a thrown string, a library blowing up - falls through
to 500, your safety net against leaking a stack trace. Note the handler keeps all four parameters;
that signature is the only thing marking it as the error door, so keep next even unused.
⚠️ Order is everything (Phase 3's Trap 1, again). The error handler goes after all your routes. Register it early and it sits in front of routes that never produce errors during normal flow - useless
- while the real errors at the end have nowhere to land.
⚠️ The async-error trap (this one bites everyone)
The "throw and Express catches it" magic only works for synchronous code. Watch an async handler
on Express 4:
// ⚠️ EXPRESS 4: this error vanishes - it never reaches your handler
;
What just happened: when an async function throws (or an awaited promise rejects), it doesn't
throw synchronously - it returns a rejected promise. Express 4 never looks at that promise, so
the rejection floats off as an unhandled promise rejection. Your error handler is never called, the
request hangs until it times out, and your terminal prints a scary warning. The error went nowhere.
Three ways out, in order of how much you should reach for them:
Option A - try/catch and call next(err) by hand. Explicit, no dependencies, but repeated in
every async handler:
;
What just happened: try/catch turns the async rejection back into something you control. A throw
inside try - your AppError or a rejected await - lands in catch, and next(err) does the
hand-off Express 4 wouldn't. Correct, but repeating this in twenty routes rots fast.
Option B - wrap once, reuse everywhere. A tiny higher-order function wraps an async handler and auto-forwards any rejection:
const ;
;
What just happened: asyncHandler runs fn, wraps the result in Promise.resolve(...) to guarantee
a promise, and attaches .catch(next) - any rejection routes straight to the error door. Handlers go
back to clean linear code with no try/catch, and every error still lands in one place. (The popular
express-async-errors package does the same globally via a one-line require.)
💡 Express 5 fixes this at the source. A rejected promise from an async handler is forwarded to
your error handler automatically - no wrapper, no try/catch. Starting fresh, use Express 5 and write
plain async handlers. On an existing Express 4 codebase (still extremely common), reach for
asyncHandler. Knowing which world you're in is the whole game.
The 404 catch-all
The error handler covers things that go wrong. But a request to a path no route matches - GET /taks
with a typo - fires no route, so Express falls through to its bland default HTML 404. For a JSON API
you want a JSON 404, in the same shape as every other error.
The fix is a catch-all middleware placed after all your routes but before the error handler:
;
;
;
// ...all other routes...
// 1) nothing matched above → it's a 404
;
// 2) LAST: the error handler (four args)
;
What just happened: app.use(...) with no path matches every request, but registered after all real
routes, it only runs when nothing else responded - an unmatched path. It sits above the error handler
because the error handler (four args) is reserved for errors routed via next(err); this catch-all
(three args) handles "nobody answered." Together they cover both dead ends: "doesn't exist" and
"something broke."
Thin handlers, one central translator
💡 Compare to Phase 5, where each handler did its own res.status(404). Now handlers get to be thin:
they do the happy path and throw a typed error when reality disagrees, never thinking about status
codes or JSON envelopes.
;
What just happened: the "not found" decision moved into a small service function that throws
AppError('Task not found', 404). The route handler reads like a sentence - get the task, send it -
with the failure path delegated. asyncHandler forwards the throw, and the central error handler maps
its statusCode and message to the response. Every error now flows through one function: one
consistent shape, one place to log, one place to hide stack traces in production.
Recap
- Errors are routed to a special four-argument middleware
(err, req, res, next), registered last. The four-arg signature is the only thing that marks it as the error door. - Reach it by calling
next(err)or by throwing in synchronous code (Express catches sync throws automatically). - A custom
AppError extends Errorcarrying astatusCodelets handlers thrownew AppError('not found', 404); anything without astatusCodefalls through to500. - ⚠️ Async errors are the trap: Express 4 does not catch rejected promises from
asynchandlers - usetry/catch+next(err), anasyncHandlerwrapper, orexpress-async-errors. Express 5 forwards them automatically. - Add a 404 catch-all after all routes and before the error handler, so unmatched paths return JSON in the same shape.
- The payoff: thin handlers throw typed errors; one central handler translates them to status + JSON.
Quick check
[
{
"q": "What makes Express treat a middleware function as an error handler?",
"choices": ["A call to app.error() instead of app.use()", "Its four-argument signature (err, req, res, next)", "Registering it before all routes", "Naming the function errorHandler"],
"answer": 1,
"explain": "Express identifies the error handler purely by its arity: four parameters (err, req, res, next). It must also be registered last, after all routes."
},
{
"q": "On Express 4, an async route handler does `await db.find()` and the promise rejects. With no try/catch and no wrapper, what happens?",
"choices": ["Express automatically routes it to the error handler", "The rejection becomes an unhandled promise rejection and the request hangs", "Express sends a 500 with the stack trace", "The 404 catch-all handles it"],
"answer": 1,
"explain": "Express 4 ignores the rejected promise an async handler returns, so the error never reaches your handler - the request hangs. Express 5 fixes this; on 4 you need try/catch, an asyncHandler wrapper, or express-async-errors."
},
{
"q": "Where does the JSON 404 catch-all middleware belong relative to the routes and the error handler?",
"choices": ["Before all routes, so it runs first", "After all routes, but before the error handler", "After the error handler", "It replaces the error handler"],
"answer": 1,
"explain": "Placed after all routes, the catch-all only runs when no route matched (an unmatched path). It must sit before the four-arg error handler, which is reserved for actual errors routed via next(err)."
}
]
Before the quiz: without looking back, say (or jot down) the core idea of this phase in your own words.
Check your understanding 3 questions
1. What makes Express treat a middleware function as an error handler?
2. On Express 4, an async route handler does `await db.find()` and the promise rejects. With no try/catch and no wrapper, what happens?
3. Where does the JSON 404 catch-all middleware belong relative to the routes and the error handler?