Express.js Error Handling Middleware — A Complete Guide
Scattering try/catch blocks with duplicated error-formatting logic across every route is a maintenance headache. Express supports centralized error-handling middleware that catches errors from anywhere in your app and formats them consistently. This guide builds one from scratch.
The Error-Handling Middleware Signature
Express recognizes error-handling middleware purely by its arity — it must declare exactly four parameters. This is not a convention, it's how Express's internals distinguish it from regular middleware.
// Regular middleware — 3 params
function logger(req, res, next) {
console.log(req.method, req.url);
next();
}
// Error middleware — must be exactly 4 params
function errorHandler(err, req, res, next) {
console.error(err.stack);
res.status(err.statusCode || 500).json({ error: err.message });
}Building a Custom Error Class
A custom error class lets you attach an HTTP status code and a machine-readable code to errors you throw intentionally, distinguishing them from unexpected bugs.
// errors/AppError.js
class AppError extends Error {
constructor(message, statusCode = 500, code = 'INTERNAL_ERROR') {
super(message);
this.statusCode = statusCode;
this.code = code;
this.isOperational = true; // distinguishes expected errors from bugs
Error.captureStackTrace(this, this.constructor);
}
}
module.exports = AppError;// routes/users.js
const AppError = require('../errors/AppError');
app.get('/users/:id', asyncHandler(async (req, res, next) => {
const user = await db.users.findById(req.params.id);
if (!user) throw new AppError('User not found', 404, 'USER_NOT_FOUND');
res.json(user);
}));The Full Error-Handling Setup
const express = require('express');
const AppError = require('./errors/AppError');
const app = express();
// Wrapper for async route handlers so rejected promises reach next()
const asyncHandler = (fn) => (req, res, next) =>
Promise.resolve(fn(req, res, next)).catch(next);
app.get('/users/:id', asyncHandler(async (req, res) => {
const user = await db.users.findById(req.params.id);
if (!user) throw new AppError('User not found', 404, 'USER_NOT_FOUND');
res.json(user);
}));
// 404 handler — for routes that don't match anything above
app.use((req, res, next) => {
next(new AppError(`Route ${req.originalUrl} not found`, 404, 'ROUTE_NOT_FOUND'));
});
// Centralized error handler — must be registered LAST
app.use((err, req, res, next) => {
const statusCode = err.statusCode || 500;
const code = err.code || 'INTERNAL_ERROR';
if (!err.isOperational) {
// Unexpected bug — log full details for debugging
console.error('UNEXPECTED ERROR:', err);
}
res.status(statusCode).json({
error: {
code,
message: err.isOperational ? err.message : 'Something went wrong',
},
});
});
app.listen(3000);Why Middleware Order Matters
- Express processes middleware top-to-bottom in the order it's registered
- Calling
next(err)skips all remaining regular middleware and jumps straight to the first error-handling middleware - The 404 handler must come after all real routes — it only runs if nothing else matched
- The error handler must be the very last
app.use()call, or it won't catch errors from routes defined after it
Common Mistakes
Error [ERR_HTTP_HEADERS_SENT]: Cannot set headers after they are sent to the client
This happens when you call res.json() or res.send() and then still call next(err) (or vice versa) in the same handler. Always return right after sending a response, or return next(err), never both.
Frequently Asked Questions
Express identifies error-handling middleware by its function signature having exactly four parameters: (err, req, res, next). Regular middleware only has three (req, res, next). This four-argument signature must be exact, even if you do not use all parameters.
Error-handling middleware must be registered last, after all routes and other app.use() calls. Express only reaches it when next(err) is called or an error is thrown, and it works by being the final fallback in the middleware chain.
Call next(err) inside a route handler or middleware. Express skips all remaining regular middleware and routes, and jumps straight to the first error-handling middleware in the chain.