Skip to main content

Command Palette

Search for a command to run...

Express.js Middleware: Error Handling Pipeline

Published
10 min read
T

Welcome to TopperBlog! 👋

I'm a tech content creator passionate about helping developers level up their careers and master cutting-edge technologies.

🎯 What I Write About: • AI/ML Engineering & LLMs • Web3 & Blockchain Development
• System Design & Architecture • Interview Preparation (FAANG) • Freelancing & Remote Work • Modern Tech Stacks (Next.js, React, Rust, TypeScript) • Performance Optimization & Best Practices

💼 Mission: Sharing practical, actionable insights that accelerate your tech career and maximize your earning potential.

📚 15+ In-Depth Guides covering everything from earning $10k/month as a freelancer to cracking FAANG interviews.

🌐 Let's connect and grow together in this amazing tech journey!

#TechBlogger #SoftwareEngineering #CareerGrowth #WebDevelopment #AIEngineering

Why Traditional Express Error Handling Fails Modern Applications

Express.js error handling was built around synchronous middleware and callbacks. The traditional pattern of passing errors to next(err) works for synchronous code, but async/await operations—now ubiquitous in modern Node.js applications—require explicit error handling that developers frequently forget.

Consider a typical 2025 Express application: it validates requests with Zod schemas, queries PostgreSQL through Prisma, calls external APIs for payment processing, publishes events to Kafka, and logs to distributed tracing systems like OpenTelemetry. Each operation can fail in different ways: validation errors (400), authentication failures (401), database deadlocks (503), external API timeouts (504), and unexpected runtime errors (500).

Traditional approaches fail because they treat all errors identically, return inconsistent response formats, lack correlation IDs for distributed tracing, don't integrate with modern observability platforms, and fail to handle async errors in middleware chains. When an async operation throws an error without proper handling, Express doesn't catch it, the request hangs, and the process may crash without logging useful context.

Modern applications also face new error scenarios: rate limiting from AI API providers, circuit breaker states in service meshes, partial failures in distributed transactions, and data validation errors from real-time ML model predictions. These require sophisticated error classification, retry logic, and client-specific error responses that traditional error handlers don't support.

Building a Production-Grade Error Handling Pipeline

A modern Express.js custom error handling pipeline must handle async errors consistently, classify errors by type and severity, provide structured error responses, integrate with observability platforms, maintain security by sanitizing error details, and support distributed tracing with correlation IDs.

Here's a production-grade implementation using TypeScript:

// errors/AppError.ts
export enum ErrorType {
  VALIDATION = 'VALIDATION_ERROR',
  AUTHENTICATION = 'AUTHENTICATION_ERROR',
  AUTHORIZATION = 'AUTHORIZATION_ERROR',
  NOT_FOUND = 'NOT_FOUND',
  CONFLICT = 'CONFLICT',
  RATE_LIMIT = 'RATE_LIMIT_EXCEEDED',
  EXTERNAL_SERVICE = 'EXTERNAL_SERVICE_ERROR',
  DATABASE = 'DATABASE_ERROR',
  INTERNAL = 'INTERNAL_ERROR',
}

export class AppError extends Error {
  constructor(
    public type: ErrorType,
    public message: string,
    public statusCode: number,
    public isOperational: boolean = true,
    public details?: Record<string, any>,
    public cause?: Error
  ) {
    super(message);
    this.name = this.constructor.name;
    Error.captureStackTrace(this, this.constructor);
  }

  toJSON() {
    return {
      type: this.type,
      message: this.message,
      ...(this.details && { details: this.details }),
    };
  }
}

export class ValidationError extends AppError {
  constructor(message: string, details?: Record<string, any>) {
    super(ErrorType.VALIDATION, message, 400, true, details);
  }
}

export class AuthenticationError extends AppError {
  constructor(message: string = 'Authentication required') {
    super(ErrorType.AUTHENTICATION, message, 401, true);
  }
}

export class ExternalServiceError extends AppError {
  constructor(service: string, cause?: Error) {
    super(
      ErrorType.EXTERNAL_SERVICE,
      `External service ${service} unavailable`,
      503,
      true,
      { service },
      cause
    );
  }
}

The error classification system distinguishes between operational errors (expected failures like validation errors) and programmer errors (bugs). This distinction determines whether to retry operations, how to log errors, and whether to restart the process.

// middleware/asyncHandler.ts
import { Request, Response, NextFunction, RequestHandler } from 'express';

export const asyncHandler = (fn: RequestHandler) => {
  return (req: Request, res: Response, next: NextFunction) => {
    Promise.resolve(fn(req, res, next)).catch(next);
  };
};

The async handler wrapper ensures all async middleware errors propagate to the error handling pipeline. Without this, unhandled promise rejections crash the application or leave requests hanging.

// middleware/errorHandler.ts
import { Request, Response, NextFunction } from 'express';
import { AppError, ErrorType } from '../errors/AppError';
import { logger } from '../utils/logger';
import { metrics } from '../utils/metrics';

interface ErrorResponse {
  error: {
    type: string;
    message: string;
    correlationId: string;
    details?: Record<string, any>;
    timestamp: string;
  };
}

export const errorHandler = (
  err: Error | AppError,
  req: Request,
  res: Response,
  next: NextFunction
) => {
  const correlationId = req.headers['x-correlation-id'] as string || 
    crypto.randomUUID();

  // Convert unknown errors to AppError
  const error = err instanceof AppError
    ? err
    : new AppError(
        ErrorType.INTERNAL,
        'An unexpected error occurred',
        500,
        false,
        undefined,
        err
      );

  // Log error with context
  const logContext = {
    correlationId,
    type: error.type,
    statusCode: error.statusCode,
    path: req.path,
    method: req.method,
    userId: req.user?.id,
    ip: req.ip,
    userAgent: req.headers['user-agent'],
    stack: error.stack,
    cause: error.cause?.stack,
  };

  if (error.isOperational) {
    logger.warn('Operational error', logContext);
  } else {
    logger.error('Programmer error', logContext);
  }

  // Record metrics
  metrics.increment('errors.total', {
    type: error.type,
    statusCode: error.statusCode.toString(),
    path: req.route?.path || req.path,
  });

  // Sanitize error for client
  const response: ErrorResponse = {
    error: {
      type: error.type,
      message: error.statusCode >= 500 && process.env.NODE_ENV === 'production'
        ? 'An internal error occurred'
        : error.message,
      correlationId,
      timestamp: new Date().toISOString(),
      ...(error.statusCode < 500 && error.details && { details: error.details }),
    },
  };

  // Send response
  res.status(error.statusCode).json(response);

  // Handle non-operational errors
  if (!error.isOperational) {
    logger.error('Non-operational error detected, initiating graceful shutdown');
    process.emit('SIGTERM');
  }
};

This error handler provides several critical features: correlation IDs for distributed tracing, structured logging with request context, metric collection for monitoring, error sanitization to prevent information leakage, and graceful shutdown for programmer errors.

// middleware/notFoundHandler.ts
import { Request, Response, NextFunction } from 'express';
import { AppError, ErrorType } from '../errors/AppError';

export const notFoundHandler = (
  req: Request,
  res: Response,
  next: NextFunction
) => {
  next(new AppError(
    ErrorType.NOT_FOUND,
    `Route ${req.method} ${req.path} not found`,
    404,
    true,
    { method: req.method, path: req.path }
  ));
};

Integrating with Modern Observability Platforms

Production error handling requires integration with observability platforms. Here's how to connect the error pipeline with OpenTelemetry and Sentry:

// utils/logger.ts
import winston from 'winston';
import { trace, context } from '@opentelemetry/api';

const logger = winston.createLogger({
  level: process.env.LOG_LEVEL || 'info',
  format: winston.format.combine(
    winston.format.timestamp(),
    winston.format.errors({ stack: true }),
    winston.format.json()
  ),
  defaultMeta: { service: 'api' },
  transports: [
    new winston.transports.Console(),
    new winston.transports.File({ 
      filename: 'error.log', 
      level: 'error' 
    }),
  ],
});

// Add OpenTelemetry trace context to logs
const originalLog = logger.log.bind(logger);
logger.log = (level: any, message: any, meta?: any) => {
  const span = trace.getSpan(context.active());
  const traceContext = span ? {
    traceId: span.spanContext().traceId,
    spanId: span.spanContext().spanId,
  } : {};

  return originalLog(level, message, { ...meta, ...traceContext });
};

export { logger };
// utils/sentry.ts
import * as Sentry from '@sentry/node';
import { AppError } from '../errors/AppError';

export const initSentry = (app: Express) => {
  Sentry.init({
    dsn: process.env.SENTRY_DSN,
    environment: process.env.NODE_ENV,
    tracesSampleRate: 0.1,
    beforeSend(event, hint) {
      const error = hint.originalException;

      // Don't send operational errors to Sentry
      if (error instanceof AppError && error.isOperational) {
        return null;
      }

      return event;
    },
  });

  app.use(Sentry.Handlers.requestHandler());
  app.use(Sentry.Handlers.tracingHandler());
};

export const sentryErrorHandler = Sentry.Handlers.errorHandler();

Application Setup and Middleware Order

Middleware order is critical. The error handler must be registered last, after all routes:

// app.ts
import express from 'express';
import { initSentry, sentryErrorHandler } from './utils/sentry';
import { errorHandler } from './middleware/errorHandler';
import { notFoundHandler } from './middleware/notFoundHandler';
import { asyncHandler } from './middleware/asyncHandler';

const app = express();

// Initialize Sentry first
initSentry(app);

// Body parsing middleware
app.use(express.json());

// Correlation ID middleware
app.use((req, res, next) => {
  req.headers['x-correlation-id'] = 
    req.headers['x-correlation-id'] || crypto.randomUUID();
  res.setHeader('x-correlation-id', req.headers['x-correlation-id']);
  next();
});

// Routes with async handler
app.get('/api/users/:id', asyncHandler(async (req, res) => {
  const user = await userService.findById(req.params.id);
  if (!user) {
    throw new AppError(ErrorType.NOT_FOUND, 'User not found', 404);
  }
  res.json(user);
}));

// 404 handler
app.use(notFoundHandler);

// Sentry error handler (before custom handler)
app.use(sentryErrorHandler);

// Custom error handler (must be last)
app.use(errorHandler);

export default app;

Common Pitfalls and Edge Cases

Several failure modes commonly affect Express error handling pipelines. Async errors in middleware without proper wrapping cause unhandled rejections. Forgetting to call next(err) in synchronous error handling leaves requests hanging. Logging sensitive data in error details creates security vulnerabilities and compliance violations.

Error handlers that perform async operations without proper handling can fail silently. Always wrap async operations in error handlers:

export const errorHandler = (
  err: Error | AppError,
  req: Request,
  res: Response,
  next: NextFunction
) => {
  // Wrap async logging in try-catch
  (async () => {
    try {
      await logToExternalService(err);
    } catch (logError) {
      console.error('Failed to log error:', logError);
    }
  })();

  // Synchronous response
  res.status(error.statusCode).json(response);
};

Memory leaks occur when error handlers maintain references to large request objects. Always extract only necessary data for logging. Circuit breaker patterns prevent cascading failures when external services fail repeatedly:

import CircuitBreaker from 'opossum';

const breaker = new CircuitBreaker(externalApiCall, {
  timeout: 3000,
  errorThresholdPercentage: 50,
  resetTimeout: 30000,
});

breaker.fallback(() => {
  throw new ExternalServiceError('payment-service');
});

Best Practices for Production Error Handling

Implement these practices for robust error handling: classify all errors explicitly using custom error classes, include correlation IDs in every error response for distributed tracing, sanitize error messages in production to prevent information disclosure, log errors with full context but never log sensitive data like passwords or tokens, integrate with APM tools for real-time error tracking, implement circuit breakers for external service calls, use structured logging formats (JSON) for machine parsing, set up alerts for non-operational errors, implement retry logic with exponential backoff for transient failures, and test error handling paths explicitly in integration tests.

Create a monitoring dashboard that tracks error rates by type, status code distribution, error response times, and correlation between errors and specific endpoints. Set up alerts when error rates exceed thresholds or when non-operational errors occur.

Document your error types and expected client behavior in API documentation. Clients need to know which errors are retryable and which require user intervention.

FAQ

What is the difference between operational and programmer errors in Express.js?

Operational errors are expected failures that occur during normal application operation, such as validation errors, authentication failures, or external service timeouts. Programmer errors are bugs in the code, like null pointer exceptions or type errors. Operational errors should be handled gracefully and logged at warn level, while programmer errors indicate code defects requiring immediate attention and potential process restart.

How does async error handling work in Express.js middleware in 2025?

Express.js doesn't automatically catch errors from async functions. You must wrap async middleware with an async handler that catches promise rejections and passes them to next(err). Without this wrapper, unhandled promise rejections crash the application or leave requests hanging indefinitely. Modern Express applications use async/await extensively, making this wrapper essential for every async route handler.

What is the best way to integrate Express.js error handling with OpenTelemetry?

Integrate OpenTelemetry by adding trace context to error logs, recording error events as span events, setting span status to error when exceptions occur, and propagating trace context through correlation IDs. Use the OpenTelemetry SDK to instrument Express automatically, then enhance error handlers to extract trace IDs from the active span context and include them in error responses and logs.

When should you avoid sending errors to external monitoring services?

Avoid sending operational errors like validation failures or 404 errors to services like Sentry, as they create noise and consume quota. Only send non-operational errors (bugs) and high-severity operational errors like database connection failures. Implement filtering in the beforeSend hook to exclude expected errors based on error type and status code.

How do you handle errors in Express.js middleware that performs database transactions?

Wrap database operations in try-catch blocks, roll back transactions on error, and throw classified errors that indicate whether the failure is retryable. Use connection pooling with proper timeout configuration to prevent hanging connections. Implement idempotency keys for operations that modify data to safely retry failed transactions without duplicating changes.

What are the security implications of exposing error details in API responses?

Exposing detailed error messages in production can leak sensitive information about your system architecture, database schema, file paths, and internal logic. Always sanitize error messages for client responses, returning generic messages for 500-level errors while logging full details internally. Never include stack traces, SQL queries, or internal service names in production error responses.

How do you test custom error handling middleware effectively?

Write integration tests that trigger each error type, verify correct status codes and response formats, check that correlation IDs are included, confirm errors are logged with proper context, and test that non-operational errors trigger appropriate alerts. Use tools like supertest for HTTP assertions and mock external services to simulate various failure scenarios including timeouts and partial failures.

Conclusion

A production-grade Express.js custom error handling pipeline requires deliberate architecture that addresses async error propagation, error classification, observability integration, and security considerations. The patterns presented here—custom error classes, async handlers, structured logging, and monitoring integration—form the foundation for reliable error handling in modern Node.js applications.

Start by implementing the custom error classes and async handler wrapper across your existing routes. Add the centralized error handler with correlation ID support, then integrate with your observability platform. Test each error path explicitly, focusing on async operations and external service failures. Monitor error rates and response times in production, adjusting classification and alerting thresholds based on actual traffic patterns. Finally, document your error types and expected client behavior to ensure consistent error handling across your entire application ecosystem.