🛡️ Error Handling Middleware
Every API you build will fail sometimes — a database goes down, a user sends garbage, a third-party service times out. The question is not whether errors happen, but whether your app catches them gracefully or crashes with a wall of red text. Express gives you one elegant place to catch them all: the error-handling middleware.
Week 7 · Day 5 (Friday: Error Handling and Validation) · Lecture 1
🎯 Learning Objectives
By the end of this lesson, you will be able to:
- Explain how Express recognizes error-handling middleware by its four arguments
- Forward errors to a central handler with
next(err)instead of responding in every route - Handle async errors with a try/catch, an
asyncHandlerwrapper, or Express 5's automatic forwarding - Distinguish operational errors from programmer errors and treat each correctly
- Build a 404 handler plus a centralized error handler that shapes clean JSON responses
- Hide stack traces and sensitive details in production while keeping them in development
Estimated Time: 60 minutes
Practice: Wire up a complete error pipeline — 404 handler, async wrapper, and an environment-aware final handler.
In This Lesson
Why Centralize Error Handling?
Imagine a busy restaurant kitchen. Instead of every cook stopping to personally apologize to a diner when a dish burns, there's one expediter at the pass who intercepts every problem plate, decides what the customer sees, and keeps the line moving. Centralized error handling is that expediter for your API: routes don't each craft their own error responses — they just hand the problem off, and one place decides how to respond.
Without it, error handling gets copy-pasted into every route: the same res.status(500).json(...), the same logging, the same "did I remember to handle this?" anxiety. Miss one, and an unhandled error can crash the whole Node process. Centralizing gives you consistency (every error looks the same to clients), safety (nothing slips through), and maintainability (change the format once).
Notice how every path — thrown errors, forwarded errors, even requests that match no route — funnels into a single middleware. That funnel is what the rest of this lesson builds.
The Four-Argument Handler
Regular Express middleware takes three parameters: (req, res, next). Error-handling middleware takes four: (err, req, res, next). That extra leading err parameter is not just convention — it is how Express identifies the function as an error handler. Express inspects the function's arity (its number of declared parameters), and a middleware declared with exactly four arguments is routed to only when an error is in flight.
const express = require('express');
const app = express();
app.get('/api/items', (req, res) => {
res.json([{ id: 1, name: 'Widget' }]);
});
// Error-handling middleware — note the FOUR parameters.
// It runs only when something upstream passed an error along.
app.use((err, req, res, next) => {
console.error(err.stack); // log for the developer
const status = err.statusCode || 500;
res.status(status).json({
error: { message: err.message || 'Something went wrong' }
});
});
app.listen(3000, () => console.log('Server running on port 3000'));
⚠️ You must declare all four parameters
Even if you never use next, you have to list it. If you write app.use((err, req, res) => …) with only three parameters, Express counts three and treats it as a regular middleware — your error handler silently never fires. The four names are conventional but the count is mandatory.
One more rule: register your error handler last, after all your routes and other middleware. Express walks its middleware stack top to bottom, and an error handler can only catch errors from middleware defined before it.
How Errors Reach the Handler
For synchronous code, Express does the work for you. If you throw inside a route handler, Express catches it and forwards it to your error middleware automatically — no wiring required.
app.get('/api/items/:id', (req, res) => {
const { id } = req.params;
if (!/^\d+$/.test(id)) {
// A synchronous throw — Express catches this and routes it
// to the error-handling middleware for you.
const err = new Error('Item id must be numeric');
err.statusCode = 400;
throw err;
}
res.json({ id: Number(id) });
});
You can also forward an error manually by calling next(err) — passing any argument to next() tells Express "skip the remaining normal middleware and jump to the error handler." Calling next() with no argument means "continue normally." This distinction is the heart of Express error flow.
next() continues down the normal stack; next(err) diverts straight to the four-argument error handler.Async Errors & next(err)
Here is the trap that catches almost everyone: Express's automatic catching works for synchronous throws only. If you throw inside an async function (or a rejected promise settles), Express 4 does not see it — the request hangs and Node logs an unhandled rejection. Async errors must be forwarded deliberately.
Option 1 — try/catch with next(err)
app.get('/api/users/:id', async (req, res, next) => {
try {
const user = await User.findById(req.params.id);
if (!user) {
const err = new Error('User not found');
err.statusCode = 404;
throw err; // caught locally by the catch below
}
res.json(user);
} catch (err) {
next(err); // hand off to the central error handler
}
});
This works, but wrapping every async route in try/catch gets repetitive fast.
Option 2 — an asyncHandler wrapper
A tiny higher-order function removes the boilerplate. It runs your handler, and if the returned promise rejects, it pipes the error into next for you.
// A reusable wrapper: resolve the handler's promise, and
// on rejection, forward the error to Express automatically.
const asyncHandler = (fn) => (req, res, next) =>
Promise.resolve(fn(req, res, next)).catch(next);
// No try/catch needed — a thrown error is forwarded for you.
app.get('/api/users/:id', asyncHandler(async (req, res) => {
const user = await User.findById(req.params.id);
if (!user) {
const err = new Error('User not found');
err.statusCode = 404;
throw err;
}
res.json(user);
}));
✅ Express 5 forwards rejected promises for you
Express 5 (now the current major version) changed this: if an async route handler or middleware returns a rejected promise, Express 5 automatically routes the error to your error middleware — no wrapper, no try/catch. On Express 4 you still need the asyncHandler pattern (or the express-async-errors package). Writing the wrapper yourself is still worthwhile: it works on both versions and makes the intent obvious.
Operational vs Programmer Errors
Not all errors are equal. A professional error strategy splits them into two buckets, and the split drives how you respond.
| Operational errors | Programmer errors | |
|---|---|---|
| What | Expected problems in normal operation | Bugs in your code |
| Examples | Invalid input, resource not found, auth failure, DB timeout | Reading a property of undefined, calling a non-function, typos |
| Response | Show a clear, safe message to the client | Show a generic 500; fix the bug |
| Recover? | Yes — handle gracefully and continue | No — often safest to log, alert, and restart |
The common convention is an isOperational flag on the error. Errors you throw on purpose (a 404, a 400) are marked operational; anything else — an unexpected TypeError — is treated as a programmer error whose details you must not reveal to clients.
// Deliberately thrown = operational. We trust its message.
const err = new Error('Email is already registered');
err.statusCode = 409;
err.isOperational = true;
throw err;
// An accidental bug is NOT operational:
const data = undefined;
data.items.forEach(/* ... */); // TypeError — a programmer error
In the next lesson you'll formalize this with custom Error subclasses that set statusCode and isOperational automatically, so throwing the right error becomes a one-liner.
A Complete Error Pipeline
Let's assemble the pieces into a realistic setup. Three parts, in order: a 404 handler for unmatched routes, then the final error handler, both registered after your routes.
// app.js
const express = require('express');
const app = express();
app.use(express.json());
// --- Your routes ---
app.use('/api/users', require('./routes/users'));
app.use('/api/items', require('./routes/items'));
// 1) 404 handler — reached only if no route above matched.
// Build an error and forward it; don't respond here.
app.use((req, res, next) => {
const err = new Error(`Not found: ${req.method} ${req.originalUrl}`);
err.statusCode = 404;
err.isOperational = true;
next(err);
});
// 2) Centralized error handler — the single source of truth
// for what an error response looks like.
app.use((err, req, res, next) => {
const status = err.statusCode || 500;
const isDev = process.env.NODE_ENV !== 'production';
// Log every error server-side with useful context.
console.error(`[${new Date().toISOString()}] ${req.method} ${req.originalUrl}`, err);
res.status(status).json({
error: {
message: err.isOperational ? err.message : 'Internal server error',
...(isDev && { stack: err.stack }) // stack only in development
}
});
});
module.exports = app;
💡 Why the 404 handler comes before the error handler
A request that matches no route "falls through" the bottom of the stack. The 404 middleware sits there to catch that fall, turn it into a proper error object, and forward it with next(err) — where your one error handler formats it exactly like every other error. One shape for everything.
Production Safety
A stack trace is a gift to a developer and a gift to an attacker. It can reveal file paths, library versions, query structure, even snippets of internal logic. The golden rule: never send stack traces or raw error details to clients in production.
app.use((err, req, res, next) => {
const status = err.statusCode || 500;
const isProd = process.env.NODE_ENV === 'production';
// Operational errors carry a safe, client-friendly message.
// Everything else is masked behind a generic message in prod.
const message = (err.isOperational || !isProd)
? err.message
: 'Something went wrong. Please try again later.';
const body = { error: { message } };
if (!isProd) body.error.stack = err.stack; // dev convenience only
res.status(status).json(body);
});
Output — same 500, two environments
// development
{ "error": { "message": "Cannot read properties of undefined",
"stack": "TypeError: ... at /app/routes/users.js:14" } }
// production
{ "error": { "message": "Something went wrong. Please try again later." } }
Two more production essentials: guard the process against truly unexpected failures, and always log before you mask.
// Last-resort safety nets at the process level.
process.on('unhandledRejection', (reason) => {
console.error('Unhandled Rejection:', reason);
// Log/alert, then let a supervisor (pm2, Docker, systemd) restart.
});
process.on('uncaughtException', (error) => {
console.error('Uncaught Exception:', error);
process.exit(1); // a programmer error left the app in an unknown state
});
Practice & Quiz
🏋️ Exercise 1: Build the pipeline
Goal: Given the routes below, add (a) an asyncHandler wrapper, (b) a 404 handler, and (c) a centralized error handler that returns { error: { message } } with the right status code and hides the stack in production.
const express = require('express');
const app = express();
app.use(express.json());
const items = [{ id: 1, name: 'Widget' }];
app.get('/api/items/:id', /* wrap me */ async (req, res) => {
const item = items.find(i => i.id === Number(req.params.id));
if (!item) { /* throw a 404 */ }
res.json(item);
});
// TODO: 404 handler
// TODO: error handler
app.listen(3000);
💡 Hint
Define asyncHandler = fn => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next). Inside the route, build an Error, set err.statusCode = 404 and err.isOperational = true, then throw it. The error handler reads err.statusCode || 500.
✅ Solution
const asyncHandler = (fn) => (req, res, next) =>
Promise.resolve(fn(req, res, next)).catch(next);
app.get('/api/items/:id', asyncHandler(async (req, res) => {
const item = items.find(i => i.id === Number(req.params.id));
if (!item) {
const err = new Error('Item not found');
err.statusCode = 404;
err.isOperational = true;
throw err;
}
res.json(item);
}));
// 404 for unmatched routes
app.use((req, res, next) => {
const err = new Error(`Not found: ${req.originalUrl}`);
err.statusCode = 404;
err.isOperational = true;
next(err);
});
// centralized error handler (must be LAST, four args)
app.use((err, req, res, next) => {
const status = err.statusCode || 500;
const isProd = process.env.NODE_ENV === 'production';
console.error(err);
res.status(status).json({
error: {
message: (err.isOperational || !isProd)
? err.message : 'Internal server error',
...(!isProd && { stack: err.stack })
}
});
});
🏋️ Exercise 2: Spot the bug
Goal: This error handler never runs. Why? Fix it.
app.use((err, req, res) => {
res.status(500).json({ error: err.message });
});
✅ Solution
It has only three parameters, so Express treats it as normal middleware, not an error handler. Add the fourth parameter — (err, req, res, next) — even though next is unused. Also confirm it is registered after all routes.
🎯 Quick Quiz
Question 1: How does Express recognize a function as error-handling middleware?
Question 2: In Express 4, what happens if an async route handler throws without a try/catch or wrapper?
Question 3: Why mark an error as isOperational?
Best Practices & Pitfalls
✅ Do
- Keep one centralized error handler and register it last
- Forward every error with
next(err)— let routes stay focused on the happy path - Wrap async handlers (or use Express 5) so rejected promises reach the handler
- Attach a
statusCodeandisOperationalflag to errors you throw on purpose - Log the full error server-side, always, before deciding what to send
❌ Don't
- Write
res.status(500).json(...)in every route — that's what you're centralizing away - Send
err.stackor raw DB messages to clients in production - Forget the fourth parameter on the handler (a silent, maddening bug)
- Call
next(err)after you've already sent a response — you'll get "headers already sent" - Swallow errors with an empty
catch {}— a hidden failure is worse than a loud one
⚠️ "Cannot set headers after they are sent"
This classic error means you responded twice — often a route sent JSON and then called next(err), or two handlers both responded. Ensure each request path ends in exactly one res.send/res.json, and return after forwarding an error so the rest of the handler doesn't keep running.
Summary
🎉 Key Takeaways
- Error middleware is defined by its four parameters
(err, req, res, next)and must come last - Express auto-catches synchronous throws; forward async errors with
next(err), anasyncHandlerwrapper, or Express 5's automatic promise forwarding - Split errors into operational (expected, safe to show) and programmer (bugs, mask in prod)
- A 404 handler plus one centralized handler gives every error a single, consistent shape
- Never leak stack traces in production — log them server-side, send a generic message
📚 Additional Resources
- Express — Error handling guide
- Express — Migrating to Express 5 (async error forwarding)
- Node.js — Errors documentation
- MDN — Error object
🚀 What's Next?
Right now most errors we throw are for bad input — and hand-checking every field is tedious and error-prone. The next lesson introduces a library that does it declaratively: Input Validation with Express-validator, where you'll build validate → sanitize → handle pipelines that catch bad data before it ever reaches your logic.
🎉 Your API just got a safety net
One handler now catches everything — synchronous, asynchronous, and unmatched routes alike. That's the foundation every production Express app is built on.