Express Error Handling Architecture
Express.js treats error handling as a first-class concern. Errors flow through the middleware stack until they reach an error-handling middleware — a special function with four parameters: (err, req, res, next). All other middleware has three.
The key rule: call next(err) to skip to the nearest error handler. Calling next() without an argument moves to the next *regular* middleware.
Request → Middleware A → Middleware B → Error!
↓
Error Handler (err, req, res, next)
The Four-Parameter Error Middleware
Error middleware must be registered after all routes and regular middleware:
const express = require('express');
const app = express();
// Regular routes
app.get('/users/:id', (req, res, next) => {
const user = db.find(req.params.id);
if (!user) {
const err = new Error('User not found');
err.status = 404;
return next(err); // Jump to error handler
}
res.json(user);
});
// Error-handling middleware — must have exactly 4 params
app.use((err, req, res, next) => {
const status = err.status || err.statusCode || 500;
res.status(status).json({
error: {
message: err.message,
status,
},
});
});
You can chain multiple error handlers. Call next(err) to pass control to the next one. This is useful for separating logging from response formatting:
// Logging error handler
app.use((err, req, res, next) => {
console.error(err);
next(err); // Forward to formatter
});
// Formatting error handler
app.use((err, req, res, next) => {
res.status(err.status || 500).json({ error: err.message });
});
Async Error Handling (Express 5 vs 4)
Express 4 does not catch rejected promises automatically. You must wrap every async route:
// Express 4: manual wrapper required
const asyncHandler = (fn) => (req, res, next) =>
Promise.resolve(fn(req, res, next)).catch(next);
app.get('/posts', asyncHandler(async (req, res) => {
const posts = await db.getPosts();
res.json(posts);
}));
Express 5 (currently in release candidate) automatically wraps async route handlers — rejected promises call next(err) implicitly:
// Express 5: no wrapper needed
app.get('/posts', async (req, res) => {
const posts = await db.getPosts(); // Rejection → next(err) automatically
res.json(posts);
});
Until Express 5 is stable, the express-async-errors package patches Express 4 to provide the same behavior: require('express-async-errors');
Custom Error Classes
Define a hierarchy of typed errors to carry status codes and machine-readable codes:
class AppError extends Error {
constructor(message, status = 500, code = 'internal_error') {
super(message);
this.status = status;
this.code = code;
this.name = this.constructor.name;
Error.captureStackTrace(this, this.constructor);
}
}
class NotFoundError extends AppError {
constructor(resource = 'Resource') {
super(`${resource} not found`, 404, 'not_found');
}
}
class ValidationError extends AppError {
constructor(errors) {
super('Validation failed', 422, 'validation_error');
this.errors = errors;
}
}
class UnauthorizedError extends AppError {
constructor() {
super('Authentication required', 401, 'unauthorized');
}
}
Now in routes, throw clean domain errors:
app.get('/users/:id', asyncHandler(async (req, res) => {
const user = await User.findById(req.params.id);
if (!user) throw new NotFoundError('User');
res.json(user);
}));
Centralized Error Response Format
Adopt the RFC 9457 Problem Details format for a consistent API contract:
app.use((err, req, res, next) => {
const status = err.status || 500;
const isProduction = process.env.NODE_ENV === 'production';
const body = {
type: `https://protocolcodes.com/http/${status}`,
title: err.name || 'Error',
status,
detail: err.message,
instance: req.path,
};
// Add validation errors if present
if (err.errors) body.errors = err.errors;
// Omit stack trace in production
if (!isProduction) body.stack = err.stack;
res.status(status).json(body);
});
Logging and Monitoring Errors
Separate error logging from error response formatting. Use a logging middleware that fires before the response handler:
const { createLogger, format, transports } = require('winston');
const logger = createLogger({
format: format.combine(format.timestamp(), format.json()),
transports: [new transports.Console()],
});
// Logging middleware (first error handler)
app.use((err, req, res, next) => {
const level = (err.status || 500) >= 500 ? 'error' : 'warn';
logger[level]({
message: err.message,
status: err.status,
path: req.path,
method: req.method,
requestId: req.headers['x-request-id'],
});
next(err);
});
A key principle: log 5xx errors as error level (pages on-call engineers) and 4xx errors as warn level (client mistakes, not server bugs).