Step-by-step
-
1
Define a typed HttpError class
Before writing the middleware, create a small error class that carries an HTTP status code. Throwing this instead of a generic
Errorlets the central handler distinguish intentional errors from bugs.javascript// errors/HttpError.js class HttpError extends Error { constructor(status, code, message) { super(message); this.name = 'HttpError'; this.status = status; this.code = code; } } // Convenience factories HttpError.badRequest = (msg) => new HttpError(400, 'BAD_REQUEST', msg); HttpError.unauthorized = (msg) => new HttpError(401, 'UNAUTHORIZED', msg ?? 'Authentication required'); HttpError.forbidden = (msg) => new HttpError(403, 'FORBIDDEN', msg ?? 'Access denied'); HttpError.notFound = (msg) => new HttpError(404, 'NOT_FOUND', msg ?? 'Resource not found'); module.exports = HttpError; -
2
Write the central error middleware
Express identifies an error middleware by its four-argument signature
(err, req, res, next). It must be registered after all routes and other middleware.javascript// middleware/errorHandler.js const HttpError = require('../errors/HttpError'); const logger = require('../logger'); function errorHandler(err, req, res, next) { // Distinguish operational errors (thrown intentionally) from programmer errors (bugs) const isOperational = err instanceof HttpError; const status = isOperational ? err.status : 500; const code = isOperational ? err.code : 'INTERNAL_ERROR'; const message = isOperational ? err.message : 'An unexpected error occurred'; // Log programmer errors with full detail; operational errors at warn level if (!isOperational) { logger.error({ err, req: { method: req.method, url: req.url } }, 'Unhandled error'); } else { logger.warn({ code, status, url: req.url }, err.message); } res.status(status).json({ error: { code, message } }); } module.exports = errorHandler; -
3
Register the handler last in app.js
Express will only call the error middleware if a previous middleware or route passes an error to
next(err)— or throws synchronously. Register it as the very lastapp.usecall.javascriptconst express = require('express'); const errorHandler = require('./middleware/errorHandler'); const userRoutes = require('./routes/users'); const app = express(); app.use(express.json()); app.use('/api/users', userRoutes); // Must be last app.use(errorHandler); module.exports = app; -
4
Wrap async route handlers to catch rejections
Express 4 does not catch promise rejections automatically. An unhandled rejection silently kills the request. Wrap every async handler with a small utility so rejections are forwarded to the error middleware.
javascript// utils/asyncHandler.js const asyncHandler = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next); module.exports = asyncHandler; // Usage in a route file: const asyncHandler = require('../utils/asyncHandler'); const HttpError = require('../errors/HttpError'); router.get('/:id', asyncHandler(async (req, res) => { const user = await User.findById(req.params.id); if (!user) throw HttpError.notFound('User not found'); res.json(user); })); -
5
Upgrade to Express 5 for native async support
Express 5 (released in 2024) catches rejected promises from route handlers automatically — the
asyncHandlerwrapper is no longer needed. If you are starting a new project, use Express 5.javascriptnpm install express@5 // Express 5 — no asyncHandler needed router.get('/:id', async (req, res) => { const user = await User.findById(req.params.id); if (!user) throw HttpError.notFound('User not found'); res.json(user); }); -
6
Separate operational errors from programmer errors
This distinction matters for on-call processes. An operational error (user not found, validation failure) is expected and safe to surface to the client. A programmer error (cannot read property of undefined) signals a bug — it should trigger an alert, the full stack trace should go to your logging system, and the response to the client should be a generic 500 with no detail.
javascript// In the central error handler: if (!isOperational) { // Page a human — something is wrong in the code logger.error({ err }, 'Programmer error — alerting on-call'); // In some setups you want to restart the process after a programmer error: // process.exit(1); // let your process manager (PM2, systemd) restart it } -
7
Return a consistent error shape
Every error response from your API should look the same. Clients should not have to guess whether the error is in
err,error,message, orerrors[0]. Agree on one shape and enforce it exclusively through the central handler.json// Every error response from your API: { "error": { "code": "NOT_FOUND", // machine-readable, stable identifier "message": "User not found" // human-readable, safe to display } } // Validation errors can extend the shape: { "error": { "code": "VALIDATION_ERROR", "message": "Request validation failed", "fields": { "email": "Must be a valid email address", "age": "Must be a positive integer" } } } -
8
Never leak stack traces in production
Stack traces expose your file structure, library versions, and sometimes variable names — all useful to an attacker. The central handler should only include a stack trace in the response when the environment is explicitly set to development.
javascriptfunction errorHandler(err, req, res, next) { const isOperational = err instanceof HttpError; const status = isOperational ? err.status : 500; const code = isOperational ? err.code : 'INTERNAL_ERROR'; const message = isOperational ? err.message : 'An unexpected error occurred'; const body = { error: { code, message } }; // Stack trace only in development if (process.env.NODE_ENV === 'development') { body.error.stack = err.stack; } res.status(status).json(body); }
Tips & gotchas
- Use <code>pino</code> or <code>winston</code> for structured JSON logging — plain <code>console.error</code> is not searchable in production log aggregators.
- Always call <code>next(err)</code> inside a <code>catch</code> block rather than <code>res.json()</code> directly — routing through the central handler keeps formatting consistent.
- Add a catch-all route (<code>app.use((req, res) => res.status(404).json(...))</code>) before the error handler to produce clean 404s for unknown paths.
- Never <code>throw</code> inside the error handler itself — if it throws, Express has no fallback and the request hangs.
Wrapping up
A single error middleware, a typed error class, and an async wrapper are all you need to handle errors cleanly across an entire Express application. Add structured logging and a consistent response shape, and your API becomes significantly easier to debug, monitor, and maintain.