Error Handling

Express.js Fundamentals — 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.

Request Middleware 1 Middleware 2 Route handler ↓ next(err) or throw (skip all remaining regular middleware and routes) Error handler 1 (4 params) ↓ next(err) to chain Error handler 2 (4 params)
// 3-param: regular middleware — receives requests normally app.use((req, res, next) => { // ... next(); // advance to next middleware next(err); // jump to error handler }); // 4-param: error-handling middleware — only called when next(err) was called // The 'next' parameter MUST be declared even if unused — its presence is the // signal to Express that this is an error handler. // eslint-disable-next-line no-unused-vars app.use((err, req, res, next) => { const status = err.status ?? 500; res.status(status).json({ error: err.message }); });

⚠ 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

app.get('/sync', (req, res) => { // Express wraps this in try/catch throw new Error('sync error'); // → forwarded to error handler ✓ });

❌ Async throw — silently lost in Express 4

app.get('/async', async (req, res) => { const data = await db.query('...'); // If db.query() rejects, Express 4 does NOT // catch it — the request hangs forever. });

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.

app.get('/users/:id', async (req, res, next) => { try { const user = await db.findById(req.params.id); if (!user) return res.status(404).json({ error: 'Not found' }); res.json(user); } catch (err) { next(err); // hand to error handler } });

Fix 2 — asyncHandler wrapper

Write a small wrapper that catches rejections and calls next(err) automatically. No try/catch clutter in every route.

// src/middleware/asyncHandler.js const asyncHandler = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next); module.exports = asyncHandler; // Usage — identical API to a sync handler const asyncHandler = require('./middleware/asyncHandler'); app.get('/users/:id', asyncHandler(async (req, res) => { const user = await db.findById(req.params.id); if (!user) throw createError(404, 'User not found'); res.json(user); // Any rejection → next(err) → error handler }));

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.

// npm install express-async-errors // server.js — MUST be required before any route definitions require('express-async-errors'); // patches Express globally const express = require('express'); const app = express(); // Now async handlers just work — no try/catch, no wrapper app.get('/users/:id', async (req, res) => { const user = await db.findById(req.params.id); if (!user) throw createError(404, 'User not found'); res.json(user); });

💡 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.

// npm install http-errors const createError = require('http-errors'); throw createError(400, 'Email is required'); // BadRequest throw createError(401, 'Not authenticated'); // Unauthorized throw createError(403, 'Insufficient permissions'); // Forbidden throw createError(404, 'User not found'); // NotFound throw createError(409, 'Email already in use'); // Conflict throw createError(422, 'Validation failed'); // UnprocessableEntity throw createError(500); // InternalServerError // DIY version — no package needed function createError(status, message) { const err = new Error(message); err.status = status; return err; }

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.

// After all routes — catches any request that nothing handled app.use((req, res, next) => { next(createError(404, `Cannot ${req.method} ${req.path}`)); }); // Alternative: respond directly without going through the error handler app.use((req, res) => { res.status(404).json({ error: 'Not found' }); }); // The error handler comes AFTER the 404 middleware // eslint-disable-next-line no-unused-vars app.use((err, req, res, next) => { const status = err.status ?? err.statusCode ?? 500; res.status(status).json({ error: err.message, status }); });

A Production-Ready Error Handler

A complete error handler handles status extraction, message sanitization, logging, and content negotiation between HTML and JSON clients.

// src/middleware/errorHandler.js const isDev = process.env.NODE_ENV !== 'production'; // eslint-disable-next-line no-unused-vars function errorHandler(err, req, res, next) { // 1. Determine the HTTP status const status = err.status ?? err.statusCode ?? 500; // 2. Sanitize the message: never expose internals to the client const message = status < 500 ? err.message // 4xx — safe to expose : 'Internal server error'; // 5xx — always generic // 3. Log everything on the server (full error + stack in dev) if (status >= 500) { console.error(`[${req.method}] ${req.path} — ${err.message}`); if (isDev) console.error(err.stack); } // 4. Respond in the format the client prefers res.status(status); if (req.accepts('json')) { const body = { error: message, status }; if (isDev && status >= 500) body.stack = err.stack; return res.json(body); } res.type('text').send(`${status} ${message}`); } module.exports = errorHandler;

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.

// Convert known library errors to HTTP errors before the main handler sees them // eslint-disable-next-line no-unused-vars app.use((err, req, res, next) => { // Convert a Sequelize unique constraint violation into a 409 Conflict if (err.name === 'SequelizeUniqueConstraintError') { return next(createError(409, 'That value is already in use')); } // Convert JWT verification failure into a 401 if (err.name === 'JsonWebTokenError') { return next(createError(401, 'Invalid token')); } next(err); // unknown error — pass on unchanged }); // Main handler: format and send // eslint-disable-next-line no-unused-vars app.use((err, req, res, next) => { const status = err.status ?? 500; const message = status < 500 ? err.message : 'Internal server error'; res.status(status).json({ error: message, status }); });

Registration Order

The sequence matters. Express processes middleware in registration order, so error handlers must come after the routes they protect.

const app = express(); // 1. Security headers and CORS app.use(helmet()); app.use(cors()); // 2. Static files app.use(express.static('public')); // 3. Body parsers app.use(express.json()); // 4. Logger app.use(morgan('dev')); // 5. Application routes app.use('/api/users', usersRouter); app.use('/api/posts', postsRouter); // 6. 404 — after all routes, before error handlers app.use((req, res, next) => { next(createError(404, `Cannot ${req.method} ${req.path}`)); }); // 7. Error handler — LAST app.use(errorHandler);

Quick Reference

PatternWhen 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 ?? 500Extract 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.

View sample solution ↗

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.

View sample solution ↗

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.

View sample solution ↗