Free Ebook cover Express.js Beyond Basics: Middleware, Architecture, and Maintainability

Express.js Beyond Basics: Middleware, Architecture, and Maintainability

New course

13 pages

Centralized Error Handling: Consistent Responses and Debuggability

Capítulo 6

Estimated reading time: 10 minutes

+ Exercise

Why centralize error handling?

In a maintainable Express application, every route and middleware should be able to fail without inventing its own response format. A single error-handling middleware gives you: (1) consistent response bodies across the entire API, (2) predictable HTTP status codes, (3) a safe boundary between what clients see and what you log internally, and (4) better debuggability via correlation IDs and structured logs.

The goal is not to hide errors; it is to normalize them into a stable contract for clients while preserving rich diagnostics for operators.

Error taxonomy: operational vs programmer errors

A practical split is:

  • Operational errors: expected failures that can happen in production (invalid input, auth failure, resource not found, upstream timeout). These should map to a known status code and a stable client message.
  • Programmer errors: bugs (undefined access, invariant violations, unexpected exceptions). These should generally become 500, with minimal client detail, and strong internal logging/alerting.

Centralized handling lets you treat these categories differently without scattering logic across controllers.

Define a normalized error shape

Start by defining an application-level error type that carries the information your handler needs. Keep it small and explicit.

Continue in our app.

You can listen to the audiobook with the screen off, receive a free certificate for this course, and also have access to 5,000 other free online courses.

Or continue reading below...
Download App

Download the app

// errors/AppError.js (CommonJS) or .ts equivalent
class AppError extends Error {
  constructor({
    code,
    message,
    status = 500,
    isOperational = true,
    details,
    cause,
  }) {
    super(message);
    this.name = 'AppError';
    this.code = code;               // stable machine-readable code
    this.status = status;           // HTTP status
    this.isOperational = isOperational;
    this.details = details;         // optional structured info (safe)
    this.cause = cause;             // original error (not for clients)
  }
}

module.exports = { AppError };

Design tips:

  • code should be stable and documented (e.g., VALIDATION_FAILED, UNAUTHORIZED, UPSTREAM_TIMEOUT).
  • message should be safe for clients (no secrets, no SQL, no stack traces).
  • details should be optional and structured (e.g., field errors). Only include it when you are confident it’s safe.
  • cause is for internal debugging/logging; never serialize it directly to clients.

Standardize the response body

Pick a response contract and keep it consistent. One common pattern:

{
  "error": {
    "code": "VALIDATION_FAILED",
    "message": "Request validation failed",
    "correlationId": "...",
    "details": { ... } // optional
  }
}

This structure is stable even if internal implementations change. Clients can reliably parse error.code and show error.message.

Correlation IDs: request tracing across logs

A correlation ID ties together all logs for a request and is returned to the client so they can report it. If you already have a request ID middleware, reuse it; otherwise, add a small one.

// middleware/correlationId.js
const crypto = require('crypto');

function correlationId(req, res, next) {
  const incoming = req.header('x-correlation-id');
  const id = incoming || crypto.randomUUID();

  req.correlationId = id;
  res.setHeader('x-correlation-id', id);
  next();
}

module.exports = { correlationId };

Use the correlation ID in logs and include it in error responses.

Step-by-step: build the centralized error handler

Step 1: normalize unknown errors into AppError

Your handler should accept anything (Error, strings, library-specific errors) and convert it into a normalized shape.

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

function normalizeError(err) {
  // Already normalized
  if (err instanceof AppError) return err;

  // Example: JSON parse errors from express.json()
  if (err && err.type === 'entity.parse.failed') {
    return new AppError({
      code: 'INVALID_JSON',
      message: 'Malformed JSON body',
      status: 400,
      isOperational: true,
      cause: err,
    });
  }

  // Fallback: treat as programmer error
  return new AppError({
    code: 'INTERNAL_ERROR',
    message: 'An unexpected error occurred',
    status: 500,
    isOperational: false,
    cause: err,
  });
}

module.exports = { normalizeError };

Step 2: map common categories (validation, auth, upstream)

Instead of letting each route decide status codes, create mapping helpers that convert known error sources into AppError.

Validation errors

Validation libraries differ, but the mapping goal is the same: return 400 with a stable code and field-level details.

// errors/mappers/mapValidationError.js
const { AppError } = require('../AppError');

function mapValidationError(err) {
  // Example shape: { name: 'ValidationError', issues: [{ path, message }] }
  if (!err || err.name !== 'ValidationError') return null;

  const fieldErrors = {};
  for (const issue of err.issues || []) {
    const key = Array.isArray(issue.path) ? issue.path.join('.') : String(issue.path);
    fieldErrors[key] = issue.message;
  }

  return new AppError({
    code: 'VALIDATION_FAILED',
    message: 'Request validation failed',
    status: 400,
    isOperational: true,
    details: { fields: fieldErrors },
    cause: err,
  });
}

module.exports = { mapValidationError };

Authentication/authorization errors

Auth failures should not leak whether a user exists or whether a token is close to valid. Keep messages generic; use codes for client behavior.

// errors/mappers/mapAuthError.js
const { AppError } = require('../AppError');

function mapAuthError(err) {
  if (!err) return null;

  if (err.name === 'UnauthorizedError' || err.code === 'INVALID_TOKEN') {
    return new AppError({
      code: 'UNAUTHORIZED',
      message: 'Authentication required',
      status: 401,
      isOperational: true,
      cause: err,
    });
  }

  if (err.code === 'FORBIDDEN') {
    return new AppError({
      code: 'FORBIDDEN',
      message: 'You do not have access to this resource',
      status: 403,
      isOperational: true,
      cause: err,
    });
  }

  return null;
}

module.exports = { mapAuthError };

Upstream failures (HTTP calls, queues, databases behind a gateway)

When a dependency fails, clients need a stable error and (sometimes) a hint to retry. Internally, you want the upstream status, timeout, and response body (if safe) in logs.

// errors/mappers/mapUpstreamError.js
const { AppError } = require('../AppError');

function mapUpstreamError(err) {
  // Example: fetch/axios-like error shapes
  const isTimeout = err && (err.code === 'ETIMEDOUT' || err.code === 'ECONNABORTED');
  if (isTimeout) {
    return new AppError({
      code: 'UPSTREAM_TIMEOUT',
      message: 'A dependent service did not respond in time',
      status: 504,
      isOperational: true,
      cause: err,
    });
  }

  const upstreamStatus = err && (err.response?.status || err.status);
  if (upstreamStatus) {
    // Map 5xx from upstream to 502; 4xx can be 502 or 424 depending on semantics.
    const status = upstreamStatus >= 500 ? 502 : 502;
    return new AppError({
      code: 'UPSTREAM_FAILURE',
      message: 'A dependent service returned an error',
      status,
      isOperational: true,
      details: { upstreamStatus },
      cause: err,
    });
  }

  return null;
}

module.exports = { mapUpstreamError };

Step 3: implement the single error-handling middleware

This middleware should: (1) normalize/map errors, (2) decide what to expose based on environment, (3) log with correlation ID, and (4) send the stable response body.

// middleware/errorHandler.js
const { normalizeError } = require('../errors/normalizeError');
const { mapValidationError } = require('../errors/mappers/mapValidationError');
const { mapAuthError } = require('../errors/mappers/mapAuthError');
const { mapUpstreamError } = require('../errors/mappers/mapUpstreamError');

function errorHandler({ logger, env }) {
  const isDev = env === 'development';

  return function (err, req, res, next) {
    // If headers already sent, delegate to Express default handler
    if (res.headersSent) return next(err);

    // Map known error sources first
    const mapped =
      mapValidationError(err) ||
      mapAuthError(err) ||
      mapUpstreamError(err);

    const appErr = normalizeError(mapped || err);

    const correlationId = req.correlationId;

    // Logging: always log stack/cause internally; never rely on client output
    const logPayload = {
      correlationId,
      code: appErr.code,
      status: appErr.status,
      isOperational: appErr.isOperational,
      path: req.originalUrl,
      method: req.method,
    };

    // Attach diagnostic info carefully
    if (appErr.cause) {
      logPayload.causeName = appErr.cause.name;
      logPayload.causeMessage = appErr.cause.message;
      logPayload.stack = appErr.cause.stack;
    } else {
      logPayload.stack = appErr.stack;
    }

    // Use severity based on type
    if (appErr.isOperational) logger.warn(logPayload);
    else logger.error(logPayload);

    // Client response: stable and safe
    const body = {
      error: {
        code: appErr.code,
        message: appErr.message,
        correlationId,
      },
    };

    // Optional: include safe details for operational errors
    if (appErr.details && appErr.isOperational) {
      body.error.details = appErr.details;
    }

    // Development-only: include debug fields (still avoid secrets)
    if (isDev) {
      body.error.debug = {
        status: appErr.status,
        name: appErr.name,
      };
      // You may include stack in dev if your environment is trusted
      body.error.debug.stack = (appErr.cause && appErr.cause.stack) || appErr.stack;
    }

    res.status(appErr.status).json(body);
  };
}

module.exports = { errorHandler };

Step 4: wire it in once, at the end

Register the correlation ID middleware early (so all logs include it) and the error handler last (so it catches errors from everything before it).

// app.js
const express = require('express');
const { correlationId } = require('./middleware/correlationId');
const { errorHandler } = require('./middleware/errorHandler');

const logger = console; // replace with pino/winston adapter

const app = express();
app.use(correlationId);
app.use(express.json());

// ... routes here ...

app.use(errorHandler({ logger, env: process.env.NODE_ENV || 'development' }));

module.exports = { app };

Consistent status code strategy

Centralization is where you enforce status code rules. A simple, consistent mapping table helps:

CategoryExample codeStatusClient message style
ValidationVALIDATION_FAILED400Generic + field details
Auth requiredUNAUTHORIZED401Generic
ForbiddenFORBIDDEN403Generic
Not foundNOT_FOUND404Generic
ConflictCONFLICT409Generic
Rate limitedRATE_LIMITED429Retry hint
Upstream timeoutUPSTREAM_TIMEOUT504Retry hint
Upstream failureUPSTREAM_FAILURE502Generic
Bug/unexpectedINTERNAL_ERROR500Generic

Keep the mapping stable; clients should not need to change because you swapped a library or refactored internals.

Safe client messages vs internal details

Rules of thumb for what never goes to clients

  • Stack traces (except in tightly controlled development environments).
  • Raw upstream response bodies (may contain secrets or internal structure).
  • SQL queries, connection strings, file paths, tokens, API keys.
  • Anything derived from err.message unless you control it.

What can go to clients (when operational)

  • Stable error.code for programmatic handling.
  • A short, generic error.message.
  • Structured validation details (field-level messages) that you generate yourself.
  • correlationId for support and debugging.

A useful practice is to treat details as “safe-by-construction”: only populate it from your own mapping functions, not from arbitrary thrown errors.

Logging guidance: stack traces without unstable responses

Centralized handling is the right place to guarantee that every error is logged with the same core fields. Prefer structured logs so you can search and aggregate by correlationId, code, and status.

// Example log payload shape
{
  "level": "error",
  "correlationId": "b7c0...",
  "code": "INTERNAL_ERROR",
  "status": 500,
  "method": "POST",
  "path": "/api/orders",
  "stack": "..."
}

Keep responses stable even if logging changes: logging can evolve (more fields, different logger), but the client response contract should remain the same. This separation prevents accidental breaking changes.

Patterns for common cases

Mapping “not found” consistently

Instead of returning different 404 bodies from different routes, throw a normalized error when a resource is missing.

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

function notFound(message = 'Resource not found') {
  return new AppError({
    code: 'NOT_FOUND',
    message,
    status: 404,
    isOperational: true,
  });
}

module.exports = { notFound };

Use it wherever needed; the handler will do the rest.

Handling upstream failures with retry semantics

If you want clients to retry, keep the response stable and use headers rather than changing the body format. For example, for rate limiting or temporary upstream issues you can set Retry-After in the handler when code matches.

// inside errorHandler before res.status(...)
if (appErr.code === 'RATE_LIMITED' && appErr.details?.retryAfterSeconds) {
  res.setHeader('Retry-After', String(appErr.details.retryAfterSeconds));
}

Environment-based output: development vs production

In production, the response should be minimal and safe. In development, you can include debug fields to speed up iteration. The key is that the top-level structure remains the same so clients and tests don’t break.

  • Production: { error: { code, message, correlationId, details? } }
  • Development: same as production, plus error.debug (stack/name/status)

If you run “development-like” environments that are still exposed (shared staging), consider disabling stack traces there as well and rely on logs.

Stability checklist for centralized error handling

  • All errors pass through one handler that always returns the same JSON envelope.
  • Every response includes a correlation ID and every log line includes the same ID.
  • Operational errors have stable codes and appropriate status codes.
  • Programmer errors become 500 with generic client messages.
  • Validation/auth/upstream errors are mapped explicitly (not by parsing random messages).
  • Stack traces are logged, not returned (except optionally in trusted development).
  • Changes to internal libraries do not change client-visible error shapes.

Now answer the exercise about the content:

In a centralized Express error-handling setup, which approach best preserves a stable client contract while keeping rich diagnostics for operators?

You are right! Congratulations, now go to the next page

You missed! Try again.

Centralized handling normalizes all failures into a stable response envelope for clients while keeping sensitive/diagnostic data (like stack traces and causes) in logs. Development can add extra debug fields without changing the top-level structure.

Next chapter

Request Validation and Data Sanitization for Express.js

Arrow Right Icon
Download the app to earn free Certification and listen to the courses in the background, even with the screen off.