Error Handling Middleware in Express.js

Building robust and maintainable error management systems

Introduction to Error Handling in Express

Error handling is a critical aspect of building reliable Express.js applications. A well-designed error handling system helps identify and resolve issues quickly, improves user experience, and makes your application more robust.

"A system is only as robust as its error handling. Everything else is just an optimistic assumption that things will work as expected."

Why Error Handling Matters

flowchart LR A[Client Request] --> B[Express Application] B --> C{Process Request} C -->|Success| D[Success Response] C -->|Error| E[Error Middleware] E --> F[Error Response] D --> G[Client] F --> G

Types of Errors in Express Applications

To handle errors effectively, it's important to understand the different types of errors that can occur in an Express application.

Synchronous Errors

These are errors that occur during normal synchronous code execution:

app.get('/example', (req, res) => {
  // This will throw a synchronous error
  const data = JSON.parse('{"invalid json"}');
  res.json(data);
});

Asynchronous Errors

These errors occur in asynchronous operations and require additional handling:

app.get('/async-example', (req, res) => {
  // Asynchronous error - not automatically caught by Express
  fs.readFile('non-existent-file.txt', (err, data) => {
    if (err) {
      // We need to handle this manually
      console.error(err);
      return res.status(500).json({ error: 'File read error' });
    }
    res.send(data);
  });
});

Promise Rejections

In modern Express applications using async/await, unhandled promise rejections can cause issues:

app.get('/promise-example', async (req, res) => {
  // Unhandled promise rejection without try/catch
  const data = await fetchDataFromDatabase();
  res.json(data);
});

Operational vs. Programming Errors

Real-World Error Examples

Error Type Example Handling Approach
Validation Error User submits invalid data Return 400 Bad Request with details
Authentication Error Invalid or expired token Return 401 Unauthorized
Database Error Connection timeout Log error, return 503 Service Unavailable
Not Found Error Resource doesn't exist Return 404 Not Found
Programming Error Trying to access property of undefined Log error, return generic 500 error in production

Default Error Handling in Express

Express comes with a built-in error handler that handles errors occurring in the application. However, this default handler is quite basic and not suitable for production applications.

How Default Error Handling Works

When an error occurs in synchronous code, Express will catch it automatically and pass it to its error handling middleware:

app.get('/example', (req, res) => {
  throw new Error('Something went wrong!');
  // Express catches this error automatically
});

Without a custom error handler, Express will:

  1. Log the error stack trace to the console
  2. Send a 500 Internal Server Error response with basic HTML
  3. Include the error message and stack trace in development mode

Default Error Response

The default error response looks something like this:

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Error</title>
</head>
<body>
<pre>Error: Something went wrong!<br>
    at /app/routes/example.js:5:9
    at Layer.handle [as handle_request] (/app/node_modules/express/lib/router/layer.js:95:5)
    ...</pre>
</body>
</html>

This is problematic for several reasons:

  • It exposes sensitive stack trace information in production
  • It returns HTML instead of JSON for API routes
  • It doesn't provide a way to handle different types of errors differently

Custom Error Handling Middleware

Custom error handling middleware in Express allows you to catch, process, and respond to errors in a consistent way across your application.

Basic Error Middleware Structure

Error-handling middleware requires four arguments (err, req, res, next):

app.use((err, req, res, next) => {
  // Error handling logic here
  console.error(err.stack);
  res.status(500).json({
    error: {
      message: 'Something went wrong!'
    }
  });
});

Middleware Placement

Error-handling middleware should be defined after all other app.use() and route calls:

const express = require('express');
const app = express();

// Regular middleware
app.use(express.json());

// Routes
app.get('/', (req, res) => {
  res.send('Hello World');
});

app.get('/error', (req, res) => {
  throw new Error('Test error');
});

// Error handling middleware (must be last!)
app.use((err, req, res, next) => {
  console.error(err.stack);
  res.status(500).json({
    error: {
      message: 'An unexpected error occurred'
    }
  });
});

Custom Error Response Format

A good error response should include:

app.use((err, req, res, next) => {
  // Set default values
  const statusCode = err.statusCode || 500;
  const errorCode = err.code || 'INTERNAL_ERROR';
  const message = err.message || 'An unexpected error occurred';
  
  // Generate request ID
  const requestId = req.id || generateRequestId();
  
  // Log error for server-side troubleshooting
  console.error(`[${requestId}] ${err.stack}`);
  
  // Send response to client
  res.status(statusCode).json({
    error: {
      code: errorCode,
      message: message,
      requestId: requestId
    }
  });
});
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[Format Error] G --> H[Log Error] H --> I[Send Error Response]

Creating a Custom Error Class

Creating custom error classes helps standardize error handling throughout your application.

Basic Custom Error

// errors/AppError.js
class AppError extends Error {
  constructor(message, statusCode, code) {
    super(message);
    this.statusCode = statusCode;
    this.code = code;
    this.isOperational = true; // Flag to identify operational errors
    
    Error.captureStackTrace(this, this.constructor);
  }
}

module.exports = AppError;

Specialized Error Types

You can create specialized error types for different scenarios:

// errors/index.js
const AppError = require('./AppError');

class NotFoundError extends AppError {
  constructor(resource = 'Resource', code = 'NOT_FOUND') {
    super(`${resource} not found`, 404, code);
  }
}

class ValidationError extends AppError {
  constructor(message = 'Validation error', errors = null, code = 'VALIDATION_ERROR') {
    super(message, 400, code);
    this.errors = errors;
  }
}

class AuthenticationError extends AppError {
  constructor(message = 'Authentication failed', code = 'AUTHENTICATION_ERROR') {
    super(message, 401, code);
  }
}

class AuthorizationError extends AppError {
  constructor(message = 'Not authorized', code = 'AUTHORIZATION_ERROR') {
    super(message, 403, code);
  }
}

class DatabaseError extends AppError {
  constructor(message = 'Database error', code = 'DATABASE_ERROR') {
    super(message, 500, code);
  }
}

module.exports = {
  AppError,
  NotFoundError,
  ValidationError,
  AuthenticationError,
  AuthorizationError,
  DatabaseError
};

Using Custom Errors in Routes

const { NotFoundError, ValidationError } = require('../errors');

app.get('/users/:id', (req, res, next) => {
  const user = findUser(req.params.id);
  
  if (!user) {
    // Use our custom error
    return next(new NotFoundError('User'));
  }
  
  res.json(user);
});

app.post('/users', (req, res, next) => {
  const { username, email, password } = req.body;
  
  // Validation example
  const errors = {};
  
  if (!username) {
    errors.username = 'Username is required';
  }
  
  if (!email || !isValidEmail(email)) {
    errors.email = 'Valid email is required';
  }
  
  if (Object.keys(errors).length > 0) {
    return next(new ValidationError('Validation failed', errors));
  }
  
  // Continue with valid data
  // ...
});

Real-World Example: E-commerce API Errors

In an e-commerce application, you might have specialized errors like:

class ProductOutOfStockError extends AppError {
  constructor(productId) {
    super(
      `Product ${productId} is out of stock`, 
      400, 
      'PRODUCT_OUT_OF_STOCK'
    );
    this.productId = productId;
  }
}

class PaymentFailedError extends AppError {
  constructor(reason, paymentId) {
    super(
      `Payment failed: ${reason}`, 
      400, 
      'PAYMENT_FAILED'
    );
    this.paymentId = paymentId;
    this.reason = reason;
  }
}

// Usage in order creation
app.post('/orders', async (req, res, next) => {
  try {
    const { items, paymentDetails } = req.body;
    
    // Check stock
    for (const item of items) {
      const inStock = await checkStock(item.productId, item.quantity);
      if (!inStock) {
        throw new ProductOutOfStockError(item.productId);
      }
    }
    
    // Process payment
    const paymentResult = await processPayment(paymentDetails);
    if (!paymentResult.success) {
      throw new PaymentFailedError(
        paymentResult.reason, 
        paymentResult.id
      );
    }
    
    // Create order if everything is successful
    const order = await createOrder(items, paymentResult.id);
    res.status(201).json(order);
  } catch (error) {
    next(error); // Pass to error handler
  }
});

Handling Different Error Types

A comprehensive error handler should process different types of errors appropriately.

Complete Error Middleware Example

// middleware/errorHandler.js
const { AppError } = require('../errors');

// Determine if we're in production
const isProduction = process.env.NODE_ENV === 'production';

const errorHandler = (err, req, res, next) => {
  console.error(err);
  
  let error = { ...err };
  error.message = err.message;
  
  // Generate request ID for tracking
  const requestId = req.id || Math.random().toString(36).substring(2, 15);
  
  // Handle mongoose duplicate key error
  if (err.code === 11000) {
    const field = Object.keys(err.keyValue)[0];
    const value = err.keyValue[field];
    const message = `Duplicate field value: ${field}. Value '${value}' already exists.`;
    error = new AppError(message, 409, 'DUPLICATE_FIELD');
  }
  
  // Handle mongoose validation error
  if (err.name === 'ValidationError') {
    const errors = {};
    Object.keys(err.errors).forEach(field => {
      errors[field] = err.errors[field].message;
    });
    error = new AppError('Validation failed', 400, 'VALIDATION_ERROR');
    error.errors = errors;
  }
  
  // Handle mongoose cast error (e.g., invalid ObjectId)
  if (err.name === 'CastError') {
    const message = `Invalid ${err.path}: ${err.value}`;
    error = new AppError(message, 400, 'INVALID_FIELD');
  }
  
  // Handle JWT errors
  if (err.name === 'JsonWebTokenError') {
    error = new AppError('Invalid token', 401, 'INVALID_TOKEN');
  }
  
  if (err.name === 'TokenExpiredError') {
    error = new AppError('Token expired', 401, 'EXPIRED_TOKEN');
  }
  
  // Determine if this is an operational error (expected error)
  const isOperationalError = error.isOperational || err.isOperational;
  
  // Response structure
  const response = {
    success: false,
    error: {
      message: error.message || 'Something went wrong',
      code: error.code || 'INTERNAL_ERROR',
      requestId
    }
  };
  
  // Add detailed errors if available and not in production
  if (error.errors && (!isProduction || isOperationalError)) {
    response.error.details = error.errors;
  }
  
  // Only include stack trace in development and for non-operational errors
  if (!isProduction && !isOperationalError) {
    response.error.stack = err.stack;
  }
  
  // Log error for monitoring and debugging
  const logData = {
    requestId,
    url: req.originalUrl,
    method: req.method,
    body: req.body,
    error: {
      message: err.message,
      name: err.name,
      code: error.code,
      stack: err.stack
    }
  };
  
  // Use structured logging in production
  if (isProduction) {
    console.error(JSON.stringify(logData));
  } else {
    console.error('Error details:', logData);
  }
  
  // Return response to client
  res.status(error.statusCode || 500).json(response);
};

module.exports = errorHandler;

Handling Specific Status Codes

For common errors, you can create specific handlers:

// Handle 404 errors (route not found)
app.use((req, res, next) => {
  const error = new AppError(`Route ${req.originalUrl} not found`, 404, 'ROUTE_NOT_FOUND');
  next(error);
});

// Handle errors
app.use(errorHandler);

Handling Asynchronous Errors

Asynchronous errors are particularly challenging to handle in Express, as they can escape your error middleware if not properly managed.

The Problem with Asynchronous Errors

// This error won't be caught by Express error handlers
app.get('/users/:id', async (req, res) => {
  // If this throws, Express won't catch it
  const user = await User.findById(req.params.id);
  
  if (!user) {
    // This will be caught
    throw new Error('User not found');
  }
  
  res.json(user);
});

Solution 1: Try/Catch in Each Route

app.get('/users/:id', async (req, res, next) => {
  try {
    const user = await User.findById(req.params.id);
    
    if (!user) {
      return next(new NotFoundError('User'));
    }
    
    res.json(user);
  } catch (error) {
    next(error); // Pass to error middleware
  }
});

Solution 2: Wrapper Function

To avoid repeating try/catch blocks, create a utility function:

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

// Usage
app.get('/users/:id', catchAsync(async (req, res, next) => {
  const user = await User.findById(req.params.id);
  
  if (!user) {
    return next(new NotFoundError('User'));
  }
  
  res.json(user);
}));

Solution 3: Express Router Wrapper

You can wrap all route handlers in a router:

// utils/asyncRouter.js
const express = require('express');

function asyncRouter() {
  const router = express.Router();
  const originalMethods = ['get', 'post', 'put', 'delete', 'patch'];
  
  // Override each method to wrap handlers in try/catch
  originalMethods.forEach(method => {
    const originalMethod = router[method];
    router[method] = function(path, ...handlers) {
      const wrappedHandlers = handlers.map(handler => {
        if (handler.length === 4) { // Error middleware (4 args)
          return handler;
        }
        
        return async (req, res, next) => {
          try {
            await handler(req, res, next);
          } catch (error) {
            next(error);
          }
        };
      });
      
      return originalMethod.call(this, path, ...wrappedHandlers);
    };
  });
  
  return router;
}

// Usage
const router = asyncRouter();

router.get('/users/:id', async (req, res) => {
  // No try/catch needed - errors will be caught automatically
  const user = await User.findById(req.params.id);
  
  if (!user) {
    throw new NotFoundError('User');
  }
  
  res.json(user);
});

Solution 4: Express Async Errors Package

A popular solution is to use the express-async-errors package:

// At the very top of your application
require('express-async-errors');
const express = require('express');
const app = express();

// Now all async errors will be caught automatically
app.get('/users/:id', async (req, res) => {
  const user = await User.findById(req.params.id);
  
  if (!user) {
    throw new NotFoundError('User');
  }
  
  res.json(user);
});

Handling Promise Rejections Outside Express

Always set up global handlers for uncaught exceptions and unhandled promise rejections:

process.on('uncaughtException', (err) => {
  console.error('UNCAUGHT EXCEPTION! 💥 Shutting down...');
  console.error(err.name, err.message, err.stack);
  // It's best to exit and let a process manager restart the app
  process.exit(1);
});

process.on('unhandledRejection', (err) => {
  console.error('UNHANDLED REJECTION! 💥 Shutting down...');
  console.error(err.name, err.message, err.stack);
  // Graceful shutdown
  server.close(() => {
    process.exit(1);
  });
});

Organizing Error Handling Code

As your application grows, it's important to organize your error handling code effectively.

Modular Error Structure

graph TD A[errors/] --> B[index.js] A --> C[AppError.js] A --> D[DatabaseError.js] A --> E[ValidationError.js] A --> F[AuthError.js] A --> G[NotFoundError.js] H[middleware/] --> I[errorHandler.js] J[utils/] --> K[catchAsync.js]
// Example folder structure
project/
  ├── errors/
  │   ├── index.js               // Exports all errors
  │   ├── AppError.js            // Base error class
  │   ├── DatabaseError.js       // Database-related errors
  │   ├── ValidationError.js     // Validation errors
  │   ├── AuthError.js           // Authentication/authorization errors
  │   └── NotFoundError.js       // 404 errors
  ├── middleware/
  │   └── errorHandler.js        // Error middleware
  ├── utils/
  │   └── catchAsync.js          // Async error wrapper
  └── app.js                     // Main app file

Error Factory Pattern

Use factories to create error instances with consistent formats:

// utils/errorFactory.js
const { 
  AppError, 
  ValidationError, 
  NotFoundError,
  DatabaseError,
  AuthenticationError,
  AuthorizationError
} = require('../errors');

class ErrorFactory {
  static validation(message, errors) {
    return new ValidationError(message, errors);
  }
  
  static notFound(resource) {
    return new NotFoundError(resource);
  }
  
  static database(message, originalError) {
    const error = new DatabaseError(message);
    error.originalError = originalError;
    return error;
  }
  
  static authentication(message) {
    return new AuthenticationError(message);
  }
  
  static authorization(message) {
    return new AuthorizationError(message);
  }
  
  static badRequest(message, code = 'BAD_REQUEST') {
    return new AppError(message, 400, code);
  }
  
  static internal(message = 'Internal server error', code = 'INTERNAL_ERROR') {
    return new AppError(message, 500, code);
  }
}

module.exports = ErrorFactory;

Centralized Error Configuration

Define error messages and codes in a central location:

// config/errors.js
const ERROR_TYPES = {
  VALIDATION: 'VALIDATION_ERROR',
  NOT_FOUND: 'NOT_FOUND',
  UNAUTHORIZED: 'UNAUTHORIZED',
  FORBIDDEN: 'FORBIDDEN',
  INTERNAL: 'INTERNAL_ERROR',
  DATABASE: 'DATABASE_ERROR',
  // Add more as needed
};

const ERROR_MESSAGES = {
  [ERROR_TYPES.VALIDATION]: 'Validation failed',
  [ERROR_TYPES.NOT_FOUND]: 'Resource not found',
  [ERROR_TYPES.UNAUTHORIZED]: 'Authentication required',
  [ERROR_TYPES.FORBIDDEN]: 'Access forbidden',
  [ERROR_TYPES.INTERNAL]: 'Internal server error',
  [ERROR_TYPES.DATABASE]: 'Database operation failed',
  // Add more as needed
};

module.exports = {
  TYPES: ERROR_TYPES,
  MESSAGES: ERROR_MESSAGES
};

Development vs. Production Error Handling

Error handling should adapt based on the environment to balance debugging needs with security concerns.

Environment-Specific Error Handling

// middleware/errorHandler.js
const errorHandler = (err, req, res, next) => {
  // Clone error to avoid modification
  let error = { ...err };
  error.message = err.message;
  error.stack = err.stack;
  
  // Process error types (like we did before)
  // ...
  
  // Development error response (detailed)
  if (process.env.NODE_ENV === 'development') {
    return res.status(error.statusCode || 500).json({
      success: false,
      error: {
        message: error.message,
        code: error.code,
        statusCode: error.statusCode,
        stack: error.stack,
        details: error.errors || null
      }
    });
  }
  
  // Production error response (limited info)
  // Hide internal errors from users
  if (!error.isOperational) {
    error.message = 'Something went wrong';
    error.statusCode = 500;
    error.code = 'INTERNAL_ERROR';
  }
  
  return res.status(error.statusCode || 500).json({
    success: false,
    error: {
      message: error.message,
      code: error.code,
      // No stack trace or detailed errors in production
    }
  });
};

Error Logging in Different Environments

// Development logging
if (process.env.NODE_ENV === 'development') {
  console.error('ERROR:', err);
  console.error('Stack:', err.stack);
}

// Production logging (structured for parsing)
if (process.env.NODE_ENV === 'production') {
  const errorLog = {
    timestamp: new Date().toISOString(),
    requestId: req.id,
    path: req.path,
    method: req.method,
    ip: req.ip,
    error: {
      name: err.name,
      message: err.message,
      code: err.code,
      statusCode: err.statusCode,
      stack: err.stack
    }
  };
  
  // In production, you might use a logging service
  console.error(JSON.stringify(errorLog));
  
  // Or send to error monitoring service
  Sentry.captureException(err);
}

Development vs. Production Response Example

Development Response:

{
  "success": false,
  "error": {
    "message": "Cannot read property 'name' of undefined",
    "code": "INTERNAL_ERROR",
    "statusCode": 500,
    "stack": "TypeError: Cannot read property 'name' of undefined\n    at getUserName (/app/services/userService.js:15:20)\n    at processRequest (/app/controllers/userController.js:24:12)\n    at async /app/routes/userRoutes.js:14:5",
    "details": null
  }
}

Production Response:

{
  "success": false,
  "error": {
    "message": "Something went wrong",
    "code": "INTERNAL_ERROR"
  }
}

Error Monitoring and Alerting

For production applications, it's crucial to monitor errors and set up alerts for critical issues.

Error Monitoring Services

Popular error monitoring services include:

Integrating Sentry Example

// Initialize Sentry at the top of your app
const Sentry = require('@sentry/node');
const express = require('express');

Sentry.init({
  dsn: process.env.SENTRY_DSN,
  environment: process.env.NODE_ENV,
  integrations: [
    // Enable HTTP calls tracing
    new Sentry.Integrations.Http({ tracing: true }),
    // Enable Express middleware tracing
    new Sentry.Integrations.Express({ app })
  ],
  tracesSampleRate: 1.0
});

const app = express();

// RequestHandler creates a separate execution context
app.use(Sentry.Handlers.requestHandler());

// TracingHandler creates a trace for every incoming request
app.use(Sentry.Handlers.tracingHandler());

// Your routes here
// ...

// Set up error handlers
// The Sentry error handler must be before any other error middleware
app.use(Sentry.Handlers.errorHandler());

// Then add your custom error handler
app.use((err, req, res, next) => {
  // Your custom error handling logic
  // ...
});

Custom Error Tagging

Add tags to categorize and search errors:

// Enhanced error middleware with Sentry
const errorHandler = (err, req, res, next) => {
  // Process error...
  
  // For monitoring in production
  if (process.env.NODE_ENV === 'production') {
    // Add context to the error
    Sentry.withScope(scope => {
      // Add user context if available
      if (req.user) {
        scope.setUser({
          id: req.user.id,
          email: req.user.email
        });
      }
      
      // Add request details
      scope.setTag('route', req.path);
      scope.setTag('method', req.method);
      
      // Add error classification
      scope.setTag('error_type', err.isOperational ? 'operational' : 'programming');
      scope.setTag('status_code', error.statusCode || 500);
      scope.setTag('error_code', error.code || 'INTERNAL_ERROR');
      
      // Add extra context
      scope.setExtra('body', req.body);
      scope.setExtra('params', req.params);
      scope.setExtra('query', req.query);
      
      // Capture the error
      Sentry.captureException(err);
    });
  }
  
  // Send response to client...
};

Setting Up Error Alerts

Configure alerts for critical errors:

  • Set up email notifications for critical errors
  • Configure Slack/Teams integrations for real-time alerts
  • Create dashboards to monitor error trends
  • Set thresholds for error rates that trigger alerts
  • Categorize errors by severity (critical, warning, info)

Most monitoring services provide these features out of the box.

Practical Exercise

Build a Complete Error Handling System

Create a robust error handling system for an Express.js API with the following components:

Requirements

  1. A base AppError class that extends Error
  2. Specialized error classes for common scenarios (NotFound, Validation, etc.)
  3. An async error handling utility to catch promise rejections
  4. A central error handling middleware that formats errors consistently
  5. Environment-specific error handling (dev vs. prod)

Steps

  1. Create the error class hierarchy
  2. Implement the catchAsync utility
  3. Set up global error handlers for unhandled exceptions
  4. Create the error handling middleware
  5. Test the system with various error scenarios

Testing Scenarios

  • Synchronous errors in route handlers
  • Asynchronous errors in async functions
  • 404 errors for non-existent routes
  • Validation errors for invalid input
  • Database errors (simulate with try/throw)

Bonus Challenge

Extend your system to include:

  • Error logging to a file
  • Custom error codes and messages
  • Request ID tracking for correlation
  • A mock Sentry integration

Best Practices Summary

Error Handling Principles

  1. Be Consistent: Use the same error format throughout your application
  2. Be Informative: Provide helpful error messages that guide users to fix issues
  3. Be Secure: Never expose sensitive information in error responses
  4. Categorize Errors: Distinguish between operational and programming errors
  5. Centralize Handling: Use middleware for consistent error processing
  6. Log Effectively: Include context for debugging but respect privacy
  7. Handle Async Errors: Ensure all asynchronous code is properly error-handled
  8. Adapt to Environment: Show detailed errors in development, not production
  9. Monitor and Alert: Track errors and get notified about critical issues
  10. Use Custom Error Classes: Create specialized error types for different scenarios

Common Mistakes to Avoid

mindmap root((Error Handling\nBest Practices)) Consistency Uniform response format Standard error codes Shared error classes Security Hide sensitive details Environment-specific responses Sanitize error messages Architecture Centralized middleware Error class hierarchy Async error handling Operations Comprehensive logging Error monitoring Alerts for critical errors

Further Reading

Next Steps

In our next lecture, we'll explore Asynchronous Error Handling, focusing on patterns and techniques for handling errors in asynchronous code, promises, and async/await functions.

Practice Activities

Basic Exercises

  1. Create a basic Express application with custom error classes for different HTTP status codes
  2. Implement a catchAsync utility function and use it in several route handlers
  3. Add a global error handler that formats errors differently in development and production
  4. Create a middleware that adds request IDs to each request and includes them in error responses
  5. Implement error logging to a file with rotation based on date

Advanced Project

Build a small "Error Management Service" for Express applications that includes: