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

How does Express know a middleware function is an error handler?

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.

Where should error-handling middleware be placed in an Express app?

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.

How do I pass an error to Express error-handling middleware?

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.

Debug Errors Faster with AI

Hit an Express error you don't recognize, like ERR_HTTP_HEADERS_SENT? Paste it into our free AI Error Explainer for an instant, plain-English fix.

Related articles