Error Handling
Express.js Fundamentals
Chapter 6 of 8 · Error Handling
Error Handling
Error handling is the part of Express that surprises developers most. The 4-parameter signature, the async trap in Express 4, and the 404-is-not-an-error rule all look odd until the reason behind each is clear. Get this right and every error in the app — validation failures, database errors, unexpected exceptions — flows through a single, consistent place. Get it wrong and you end up with a mix of scattered res.status(500).send() calls, unhandled promise rejections, and leaked stack traces.
How Express Routes Errors
When a route handler calls next(err) — or throws synchronously — Express skips every remaining regular middleware and jumps directly to the first error-handling middleware it finds. Error-handling middleware is identified by its arity: it takes exactly 4 parameters. Everything else (3 or fewer params) is regular middleware.
⚠ Never Remove the 4th Parameter
If you write (err, req, res) instead of (err, req, res, next), Express sees a 3-param function and treats it as regular middleware — errors will silently bypass it. ESLint will flag next as unused; suppress it with a comment rather than deleting it.
The Async Problem in Express 4
Express 4 wraps route handlers in a try/catch, so synchronous throws are automatically forwarded to the error handler. But async handlers are different: Express calls the function, gets back a Promise, and moves on — it doesn't await it. If that Promise rejects, there's no catch.
✅ Sync throw — works
❌ Async throw — silently lost in Express 4
Fix 1 — try/catch + next(err)
The explicit approach: catch any rejection yourself and pass it to next(). Verbose but crystal clear about what can fail.
Fix 2 — asyncHandler wrapper
Write a small wrapper that catches rejections and calls next(err) automatically. No try/catch clutter in every route.
Fix 3 — express-async-errors (the easy way)
express-async-errors monkey-patches Express's route registration so that async rejections automatically call next(err). Import it once at the top of your entry point — all async handlers in the app benefit.
💡 Express 5 — the Clean Future
Express 5 (currently express@5 on npm) automatically awaits async route handlers and forwards rejections to the error handler — no wrapper or patch needed. If you're starting a new project, install Express 5 and skip the async workarounds entirely. Express 4 stays the default install for now, so this chapter covers the workarounds you'll encounter in existing codebases.
Creating HTTP Errors
To pass a specific HTTP status code to the error handler, attach a .status (or .statusCode) property to the error. The http-errors package does this cleanly, but a small helper function works equally well.
404 — Not an Error, But Treated Like One
HTTP 404 is not a server error — it just means no route matched. Express doesn't generate a 404 automatically; instead, a request that matches nothing falls through to the end of the middleware stack. The standard pattern is a catch-all middleware (after all routes) that creates a 404 error and passes it to the error handler.
A Production-Ready Error Handler
A complete error handler handles status extraction, message sanitization, logging, and content negotiation between HTML and JSON clients.
Chaining Error Handlers
You can register multiple 4-param handlers in sequence. Each one can either respond to the client or call next(err) to pass to the next handler. This is useful for separating concerns: a first handler converts database-specific errors into HTTP errors, and a second handler formats and sends the response.
Registration Order
The sequence matters. Express processes middleware in registration order, so error handlers must come after the routes they protect.
Quick Reference
| Pattern | When to use |
|---|---|
| throw new Error() | Sync route handler — Express 4 catches it automatically |
| next(err) | Any handler — the universal way to route to the error handler |
| next(createError(404)) | 404 catch-all — route not found |
| try/catch + next(err) | Async handler in Express 4 — explicit, no dependencies |
| asyncHandler(fn) | Async handler in Express 4 — reusable wrapper, no boilerplate |
| require('express-async-errors') | Global patch — all async handlers work without wrappers |
| (err, req, res, next) => | Error-handling middleware — 4 params always, even if next unused |
| err.status ?? 500 | Extract HTTP status — falls back to 500 for untagged errors |
| status < 500 ? err.message : 'Internal...' | Sanitize: expose 4xx messages, hide 5xx internals |
Coding Challenges
Challenge 1 — asyncHandler & Error Classes
Write a reusable asyncHandler(fn) wrapper. Then build an AppError class that extends Error with a status property and an isOperational flag (operational errors are expected business-logic failures like 404/422; non-operational errors are unexpected crashes like DB connection lost). Create routes: GET /users/:id (simulate async DB lookup — throw AppError(404) if id is not in a hardcoded map); POST /users (validate req.body.name is present — throw AppError(422) if missing; throw a plain new Error('DB write failed') for id 99 to simulate a non-operational crash). Write a complete error handler that: extracts status; logs 5xx errors with the full message; sends 4xx messages verbatim and replaces 5xx with 'Internal server error'. Write supertest tests: 200 with a valid user, 404 for unknown id, 422 for missing name, 500 with sanitised message for the crash route.
Challenge 2 — 404 Catch-All & Error Chaining
Build an app with two routers: /api/products (in-memory array, CRUD via router.route()) and /api/categories (static list). Register a 404 catch-all after both routers that calls next(createError(404, ...)). Then register two chained error handlers: the first converts known library-style errors into HTTP errors (write a check for any error whose name === 'ValidationError' → createError(422, err.message); any error whose code === 'ENOENT' → createError(404, 'File not found')); the second is the final formatter that sends JSON. Add a route GET /api/trigger-validation that throws an error with name: 'ValidationError' and a route GET /api/trigger-enoent that throws one with code: 'ENOENT'. Write supertest tests: unknown route gets 404; /api/trigger-validation gets 422; /api/trigger-enoent gets 404 with "File not found"; a valid product route works normally.
Challenge 3 — express-async-errors Integration
Build an app that uses express-async-errors so async handlers need no try/catch or wrapper. Create an in-memory "task" store (id, title, done). Routes: GET /tasks (returns all); GET /tasks/:id (async, finds by id — throw a 404 error if not found); POST /tasks (async, validates title — throw 422 if missing; simulates an async save with await Promise.resolve()); PATCH /tasks/:id (async, validates the task exists and that done is a boolean — throw 422 if not). Register a 404 catch-all and an error handler that distinguishes 4xx from 5xx, logs 5xx, and responds with JSON. The key constraint: NO try/catch blocks and NO asyncHandler wrapper in any route handler — rely entirely on express-async-errors. Write supertest tests covering: listing tasks; creating a valid task; 422 for missing title; fetching by id; 404 for unknown id; patching done; 422 for invalid done type.