Skip to main content

🛡️ 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.
flowchart LR A[Client Request] --> B[Express App] B --> C{Process Request} C -->|Success| D[Success Response] C -->|Error| E[Error Middleware] E --> F[Formatted Error Response] D --> G[Client] F --> G

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 typeExampleCorrect response
ValidationUser submits invalid data400 Bad Request + field details
AuthenticationMissing or expired token401 Unauthorized
AuthorizationLogged in but not permitted403 Forbidden
Not foundResource does not exist404 Not Found
Programming bugReading a property of undefinedLog 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:

  1. Log the stack trace to the console.
  2. Respond with 500 Internal Server Error.
  3. 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.

flowchart TB A[Request] --> B[Regular Middleware] B --> C[Route Handlers] C --> D{Error?} D -->|No| E[Success Response] D -->|Yes| F[Error Middleware] F --> G[Normalize & Format] G --> H[Log with Context] H --> I[Send JSON Response]

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.message for non-operational errors to production clients.
  • Never return HTML from an API route — always JSON.
  • Don't forget to set NODE_ENV=production in your deploy; the flag is what gates everything above.
DevelopmentProduction
Message (bug)Real message"Something went wrong"
Stack traceIncludedHidden
Field detailsIncludedOnly for operational errors
Server logFullFull (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:

  1. Create errors/AppError.js and an errors/index.js with NotFoundError and ValidationError.
  2. Add a GET /users/:id route that throws NotFoundError('User') when the id is not "1".
  3. Add a POST /users route that throws ValidationError when req.body.email is missing.
  4. Add a 404 catch-all and register your central handler last.
  5. 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 isOperational flag.
  • 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.