Programming Intermediate 9 min

How to Handle Errors Centrally in an Express App

Scattered try/catch blocks that each format their own error response are a maintenance burden and inevitably produce inconsistent output. Centralizing error handling in a single middleware gives you one place to format, log, and decide what to expose — with no risk of leaking a stack trace into a production response.

Step-by-step

  1. 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 Error lets 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. 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. 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 last app.use call.

    javascript
    const 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. 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. 5

    Upgrade to Express 5 for native async support

    Express 5 (released in 2024) catches rejected promises from route handlers automatically — the asyncHandler wrapper is no longer needed. If you are starting a new project, use Express 5.

    javascript
    npm 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. 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. 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, or errors[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. 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.

    javascript
    function 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.

#Node.js #Express #Errors
Back to all guides

Need Help With Your Project?

Book a free 30-minute consultation to discuss your technical challenges and explore solutions together.