🛡️ Error Handling Middleware
Every route you write will eventually fail — a database times out, a user sends garbage, a bug slips through. What separates a hobby project from a production API is how gracefully it handles that failure. In this lesson you'll build one central place where every error is caught, shaped into a clean JSON response, and logged for you to find later.
🎯 Learning Objectives
By the end of this lesson, you will be able to:
- Explain how Express recognizes error-handling middleware by its four-argument signature and where it must be placed
- Distinguish operational errors from programming errors and treat each appropriately
- Build a reusable AppError class hierarchy for consistent, typed errors
- Write a single central error handler that formats responses and normalizes third-party errors
- Return secure production responses that never leak stack traces while keeping development output rich
Estimated Time: 35–45 minutes • Difficulty: Intermediate
Hands-on: Build a complete error pipeline — custom error classes, a 404 catch-all, and a central handler — and test it against several failure scenarios.
In This Lesson
Why Error Handling Matters
An Express app without a deliberate error strategy is an app that will one day crash on a request you never imagined. Good error handling is not an afterthought bolted on at the end — it is a load-bearing part of the architecture, and it pays off in four concrete ways:
- User experience — clients receive a clear, consistent message instead of a cryptic HTML stack trace.
- Security — internal details (file paths, SQL, library versions) never leak to the outside world.
- Debuggability — every failure is logged with enough context to reproduce it.
- Stability — a single bad request is contained rather than taking down the whole process.
💡 A useful mindset: A system is only as robust as its error handling. Everything else is an optimistic assumption that things will go right. Design for the request that fails, and the requests that succeed take care of themselves.
Types of Errors
Before you can handle errors, you need to recognize the shapes they come in. Express treats them very differently depending on when they occur.
Synchronous errors
Thrown during normal, synchronous execution. Express catches these automatically and routes them to your error middleware:
app.get('/example', (req, res) => {
// Throws synchronously — Express catches this for you
const data = JSON.parse('{ not valid json }');
res.json(data);
});
Asynchronous callback errors
Errors delivered through a callback are not automatically caught — you must forward them yourself with next(err):
const fs = require('node:fs');
app.get('/file', (req, res, next) => {
fs.readFile('missing.txt', (err, data) => {
if (err) return next(err); // Hand the error to Express
res.send(data);
});
});
Promise rejections (async/await)
In Express 4, a rejected promise inside an async handler escapes the framework and becomes an unhandled rejection. (Express 5, released in 2024, finally forwards these automatically — more on that in the next lesson.)
// Express 4: if this rejects, the error is NOT caught
app.get('/data', async (req, res) => {
const data = await fetchFromDatabase();
res.json(data);
});
📖 Operational vs. Programming Errors
Operational errors are expected problems in a correct program: invalid input, a missing record, a network timeout, a failed login. You anticipate these and respond with a helpful message and the right status code.
Programming errors are bugs: a TypeError, a typo, calling a method on undefined. You cannot recover meaningfully from these — you log them, return a generic 500, and fix the code.
Tracking which is which (via an isOperational flag) is the single most useful distinction in this whole lesson.
| Error type | Example | Correct response |
|---|---|---|
| Validation | User submits invalid data | 400 Bad Request + field details |
| Authentication | Missing or expired token | 401 Unauthorized |
| Authorization | Logged in but not permitted | 403 Forbidden |
| Not found | Resource does not exist | 404 Not Found |
| Programming bug | Reading a property of undefined | Log it; return generic 500 |
Express's Default Handler
Express ships with a built-in error handler so your app never crashes on a synchronous throw. When an error reaches the end of the middleware chain without being handled, Express will:
- Log the stack trace to the console.
- Respond with
500 Internal Server Error. - Send an HTML page containing the message and — in development — the full stack trace.
⚠️ Why the default is not enough
The default handler is a safety net, not a strategy. It returns HTML (wrong for a JSON API), it can leak the stack trace if NODE_ENV is misconfigured, and it gives you no way to treat a 404 differently from a 401. Every real API replaces it with a handler of its own.
Custom Error Middleware
Express recognizes error-handling middleware by one thing and one thing only: its function takes four arguments — (err, req, res, next). That extra first parameter is the signal.
app.use((err, req, res, next) => {
console.error(err.stack);
res.status(500).json({
error: { message: 'Something went wrong' }
});
});
⚠️ Placement is everything
Error middleware must be registered after every route and every other app.use(). Middleware runs in the order you declare it, so an error handler placed at the top would never see errors from routes below it.
const express = require('express');
const app = express();
app.use(express.json());
app.get('/', (req, res) => res.send('Hello World'));
app.get('/boom', (req, res) => {
throw new Error('Test error');
});
// Error handler — LAST, after all routes
app.use((err, req, res, next) => {
console.error(err.stack);
res.status(err.statusCode || 500).json({
error: {
code: err.code || 'INTERNAL_ERROR',
message: err.message || 'An unexpected error occurred'
}
});
});
A good error response gives the client enough to act on without exposing internals: an appropriate status code, a human-readable message, a machine-readable code, and optionally a request ID so a user can quote it in a support ticket.
A Custom Error Class Hierarchy
Throwing raw new Error('...') everywhere gives your handler nothing to work with — no status code, no type. The fix is a small hierarchy of error classes. Start with a base class:
// errors/AppError.js
class AppError extends Error {
constructor(message, statusCode, code) {
super(message);
this.statusCode = statusCode;
this.code = code;
this.isOperational = true; // We threw this on purpose
Error.captureStackTrace(this, this.constructor);
}
}
module.exports = AppError;
Then derive specialized types so route code reads like plain English:
// errors/index.js
const AppError = require('./AppError');
class NotFoundError extends AppError {
constructor(resource = 'Resource') {
super(`${resource} not found`, 404, 'NOT_FOUND');
}
}
class ValidationError extends AppError {
constructor(message = 'Validation failed', details = null) {
super(message, 400, 'VALIDATION_ERROR');
this.details = details;
}
}
class AuthenticationError extends AppError {
constructor(message = 'Authentication required') {
super(message, 401, 'AUTHENTICATION_ERROR');
}
}
class AuthorizationError extends AppError {
constructor(message = 'Not authorized') {
super(message, 403, 'AUTHORIZATION_ERROR');
}
}
module.exports = {
AppError, NotFoundError, ValidationError,
AuthenticationError, AuthorizationError
};
Now routes throw meaningful errors, and the central handler already knows the status code and type:
const { NotFoundError, ValidationError } = require('../errors');
app.get('/users/:id', (req, res, next) => {
const user = findUser(req.params.id);
if (!user) return next(new NotFoundError('User'));
res.json(user);
});
app.post('/users', (req, res, next) => {
const { username, email } = req.body;
const details = {};
if (!username) details.username = 'Username is required';
if (!email || !isValidEmail(email)) details.email = 'Valid email is required';
if (Object.keys(details).length > 0) {
return next(new ValidationError('Validation failed', details));
}
// ... continue with valid data
});
💡 Why isOperational is the key flag
Every error you deliberately throw through AppError carries isOperational: true. A stray TypeError from a bug does not. In the central handler you use exactly this flag to decide: show the real message (operational) or hide it behind a generic 500 (programming bug). One boolean, huge payoff.
A Complete Central Handler
Here is a production-grade handler in its own module. It normalizes common third-party errors (Mongoose, JWT) into your AppError shape, logs with context, and returns a consistent JSON envelope.
// middleware/errorHandler.js
const { AppError } = require('../errors');
const crypto = require('node:crypto');
const isProduction = process.env.NODE_ENV === 'production';
function normalize(err) {
// Mongoose duplicate key
if (err.code === 11000) {
const field = Object.keys(err.keyValue)[0];
return new AppError(`Duplicate value for '${field}'`, 409, 'DUPLICATE_FIELD');
}
// Mongoose validation
if (err.name === 'ValidationError' && err.errors) {
const e = new AppError('Validation failed', 400, 'VALIDATION_ERROR');
e.details = Object.fromEntries(
Object.entries(err.errors).map(([k, v]) => [k, v.message])
);
return e;
}
// Invalid Mongo ObjectId
if (err.name === 'CastError') {
return new AppError(`Invalid ${err.path}: ${err.value}`, 400, 'INVALID_FIELD');
}
// JSON Web Token errors
if (err.name === 'JsonWebTokenError') {
return new AppError('Invalid token', 401, 'INVALID_TOKEN');
}
if (err.name === 'TokenExpiredError') {
return new AppError('Token expired', 401, 'EXPIRED_TOKEN');
}
return err;
}
function errorHandler(err, req, res, next) {
const requestId = req.id || crypto.randomUUID();
const error = normalize(err);
const statusCode = error.statusCode || 500;
const isOperational = error.isOperational === true;
// Log full detail server-side, always
console.error(JSON.stringify({
requestId,
method: req.method,
url: req.originalUrl,
name: err.name,
message: err.message,
stack: err.stack
}));
// Build the client-facing envelope
const body = {
success: false,
error: {
code: error.code || 'INTERNAL_ERROR',
// Hide non-operational messages in production
message: (!isOperational && isProduction)
? 'Something went wrong'
: error.message,
requestId
}
};
if (error.details) body.error.details = error.details;
if (!isProduction && !isOperational) body.error.stack = err.stack;
res.status(isOperational ? statusCode : (isProduction ? 500 : statusCode)).json(body);
}
module.exports = errorHandler;
Wire it up with a 404 catch-all just before the handler, so any unmatched route becomes a proper operational error:
const { AppError } = require('./errors');
const errorHandler = require('./middleware/errorHandler');
// ... all your routes above ...
// 404 for anything that fell through
app.use((req, res, next) => {
next(new AppError(`Route ${req.originalUrl} not found`, 404, 'ROUTE_NOT_FOUND'));
});
// Central error handler — the very last app.use
app.use(errorHandler);
Client receives, for a missing user:
{
"success": false,
"error": {
"code": "NOT_FOUND",
"message": "User not found",
"requestId": "8f3c1e0a-..."
}
}
Development vs. Production
The same error should look different depending on where it lands. In development you want everything; in production you want to reveal nothing that helps an attacker or confuses a user.
✅ Do
- Send stack traces and full details only when
NODE_ENV !== 'production'. - Log the complete error server-side in every environment.
- Collapse programming errors to a generic 500 message in production.
- Attach a request ID so users can reference a specific failure.
⚠️ Don't
- Never echo raw
err.messagefor non-operational errors to production clients. - Never return HTML from an API route — always JSON.
- Don't forget to set
NODE_ENV=productionin your deploy; the flag is what gates everything above.
| Development | Production | |
|---|---|---|
| Message (bug) | Real message | "Something went wrong" |
| Stack trace | Included | Hidden |
| Field details | Included | Only for operational errors |
| Server log | Full | Full (structured) |
Hands-on Exercise
🏋️ Build a Complete Error Pipeline
Objective: Wire a small Express app with custom error classes, a 404 catch-all, and a central handler, then confirm each failure returns the right status and shape.
Instructions:
- Create
errors/AppError.jsand anerrors/index.jswithNotFoundErrorandValidationError. - Add a
GET /users/:idroute that throwsNotFoundError('User')when the id is not"1". - Add a
POST /usersroute that throwsValidationErrorwhenreq.body.emailis missing. - Add a 404 catch-all and register your central handler last.
- Test with curl or your browser:
/users/99,/users/1, a POST with no body, and a nonexistent route.
💡 Hint
Remember the handler is recognized by its four parameters — (err, req, res, next) — and it must be the last app.use(). Use express.json() near the top so req.body is populated on the POST.
✅ Solution
const express = require('express');
const app = express();
app.use(express.json());
class AppError extends Error {
constructor(message, statusCode, code) {
super(message);
this.statusCode = statusCode;
this.code = code;
this.isOperational = true;
Error.captureStackTrace(this, this.constructor);
}
}
class NotFoundError extends AppError {
constructor(resource = 'Resource') { super(`${resource} not found`, 404, 'NOT_FOUND'); }
}
class ValidationError extends AppError {
constructor(details) { super('Validation failed', 400, 'VALIDATION_ERROR'); this.details = details; }
}
app.get('/users/:id', (req, res, next) => {
if (req.params.id !== '1') return next(new NotFoundError('User'));
res.json({ id: 1, name: 'Ada' });
});
app.post('/users', (req, res, next) => {
if (!req.body?.email) return next(new ValidationError({ email: 'Email is required' }));
res.status(201).json({ id: 2, email: req.body.email });
});
app.use((req, res, next) => {
next(new AppError(`Route ${req.originalUrl} not found`, 404, 'ROUTE_NOT_FOUND'));
});
app.use((err, req, res, next) => {
const status = err.statusCode || 500;
console.error(err.stack);
res.status(status).json({
success: false,
error: { code: err.code || 'INTERNAL_ERROR', message: err.message, details: err.details }
});
});
app.listen(3000, () => console.log('http://localhost:3000'));
🎯 Quick Quiz
Question 1: How does Express identify a function as error-handling middleware?
Question 2: Why is the isOperational flag useful in a central handler?
Question 3: Where must the central error handler be registered?
Summary & Quiz
🎉 Key Takeaways
- Error middleware is recognized by its four-argument signature and must be registered last.
- Separate operational errors (expected) from programming errors (bugs) with an
isOperationalflag. - A small AppError hierarchy makes route code readable and gives the handler status codes and types for free.
- A single central handler normalizes third-party errors, logs with context, and returns one consistent JSON envelope.
- Reveal detail in development, hide it in production — gated by
NODE_ENV.
📚 Further Reading
🚀 What's Next?
Your handler now catches synchronous errors cleanly — but async route handlers can still slip past it. Next we'll tackle asynchronous error handling: the catchAsync wrapper, Express 5's built-in support, and global handlers for unhandled rejections.
🎉 Well done!
You've built the backbone of a resilient API. Every route you write from here can fail safely.