Express Error Handling Middleware in Node.js

This article explains how to design and implement a robust, centralized error-handling system in a Node.js Express application. You will learn how to create a custom error class, build a centralized error-handling middleware, and gracefully handle asynchronous errors without cluttering your route handlers with repetitive try-catch blocks.

1. Create a Custom Error Class

To distinguish between operational errors (known issues like validation failures or resource shortages) and programmer errors (bugs), create a custom error class that extends the built-in JavaScript Error class. This allows you to attach properties like HTTP status codes.

class AppError extends Error {
  constructor(message, statusCode) {
    super(message);
    this.statusCode = statusCode;
    this.status = `${statusCode}`.startsWith('4') ? 'fail' : 'error';
    this.isOperational = true;

    Error.captureStackTrace(this, this.constructor);
  }
}

module.exports = AppError;

2. Build the Centralized Error Middleware

Express recognizes error-handling middleware by its signature. It must accept exactly four arguments: (err, req, res, next). Place this middleware at the end of your middleware stack to catch all errors passed via next().

const globalErrorHandler = (err, req, res, next) => {
  err.statusCode = err.statusCode || 500;
  err.status = err.status || 'error';

  if (process.env.NODE_ENV === 'development') {
    res.status(err.statusCode).json({
      status: err.status,
      error: err,
      message: err.message,
      stack: err.stack
    });
  } else {
    // Production mode: do not leak leak stack traces or internal details
    if (err.isOperational) {
      res.status(err.statusCode).json({
        status: err.status,
        message: err.message
      });
    } else {
      // Log generic/programming errors to the console or log service
      console.error('ERROR 💥', err);
      res.status(500).json({
        status: 'error',
        message: 'Something went wrong!'
      });
    }
  }
};

module.exports = globalErrorHandler;

3. Handle Asynchronous Errors Automatically

Writing try/catch blocks in every asynchronous route handler leads to repetitive code. To solve this, implement a wrapper function that catches rejected promises and forwards them to the next function.

const catchAsync = fn => {
  return (req, res, next) => {
    fn(req, res, next).catch(next);
  };
};

You can then wrap your controller functions like this:

const getUser = catchAsync(async (req, res, next) => {
  const user = await User.findById(req.params.id);
  
  if (!user) {
    return next(new AppError('No user found with that ID', 404));
  }
  
  res.status(200).json({ status: 'success', data: { user } });
});

4. Register the Middleware in the Application

To ensure Express catches all errors, register the global error handler as the very last middleware function in your app.js or server setup file, after all your route definitions.

const express = require('express');
const AppError = require('./AppError');
const globalErrorHandler = require('./globalErrorHandler');

const app = express();

app.use(express.json());

// Example Route
app.get('/api/users/:id', getUser);

// Handle undefined routes
app.all('*', (req, res, next) => {
  next(new AppError(`Can't find ${req.originalUrl} on this server!`, 404));
});

// Mount the global error handling middleware
app.use(globalErrorHandler);

app.listen(3000, () => {
  console.log('Server is running on port 3000');
});