A Production-Grade Error Handler

One error handler to rule them all: typed errors, safe client messages, correlated logs, and no stack traces leaking to users.

A centralized error handler is the single place that turns any thrown value into an HTTP response. Get it right once and every route inherits consistent behavior. The recipe has four responsibilities.

1. Distinguish operational from programmer errors

An operational error is expected (not found, validation failed, unauthorized) — you designed for it and it carries a safe status and message. A programmer error is a bug (undefined is not a function). Never leak the second kind to clients; log it fully, return a generic 500.

2. Normalize the status and body

const status = err.status ?? 500;
const body = { error: status < 500 ? err.message : 'Internal Server Error' };

3. Log with correlation

Attach the request id and the full stack to your logger — never to the response body in production.

4. Respect NODE_ENV

In development it is fine to echo the stack to speed debugging; in production, hide it. Keep the four-argument signature or Express treats the function as ordinary middleware.

Example

Example · javascript
// errors.js
export class AppError extends Error {
  constructor(message, status = 500, { expose = status < 500 } = {}) {
    super(message);
    this.status = status;
    this.expose = expose;      // may the message be shown to the client?
    this.isOperational = true; // we anticipated this one
  }
}

// middleware/errorHandler.js
export function errorHandler(logger) {
  const isProd = process.env.NODE_ENV === 'production';

  // Keep the four-arg signature — this is load-bearing.
  return (err, req, res, next) => {
    if (res.headersSent) return next(err); // let Express abort the stream

    const status = Number.isInteger(err.status) ? err.status : 500;
    const expose = err.expose ?? status < 500;

    logger.error({
      requestId: req.id,
      status,
      msg: err.message,
      stack: err.stack,
      operational: Boolean(err.isOperational),
    });

    res.status(status).json({
      error: expose ? err.message : 'Internal Server Error',
      requestId: req.id,
      ...(isProd ? {} : { stack: err.stack }),
    });
  };
}

// app.js (last middleware)
// app.use(errorHandler(logger));

When to use it

  • A production API's error handler sends structured JSON to the client but also ships error details to a Sentry DSN for monitoring.
  • A developer creates separate error handler modules for operational errors (e.g., 404, validation) and programmer errors (e.g., 500), applying different logging levels.
  • An error handler checks err.isJoi to detect Joi validation errors and automatically maps them to a 422 status with detailed field messages.

More examples

Differentiating error types

Distinguishes operational errors (safe to expose) from unexpected programmer errors (hide message, log internally).

Example · js
class AppError extends Error {
  constructor(message, status) { super(message); this.status = status; this.isOperational = true; }
}

app.use((err, req, res, next) => {
  if (err.isOperational) {
    return res.status(err.status).json({ error: err.message });
  }
  // Unexpected programmer error — log and hide details
  console.error('UNHANDLED:', err);
  res.status(500).json({ error: 'Internal Server Error' });
});

Error handler with Sentry reporting

Registers Sentry's error handler middleware before the custom handler so all 500-level errors are captured and reported.

Example · js
const Sentry = require('@sentry/node');

app.use(Sentry.Handlers.errorHandler()); // must be before custom error handler

app.use((err, req, res, next) => {
  const status = err.status || 500;
  res.status(status).json({
    error: status < 500 ? err.message : 'Internal Server Error',
    requestId: req.id,
  });
});

Validation-aware error handler

Handles Mongoose ValidationError, JWT errors, and duplicate-key errors with appropriate status codes before falling back to a generic 500.

Example · js
app.use((err, req, res, next) => {
  if (err.name === 'ValidationError') {              // Mongoose
    return res.status(422).json({ errors: Object.values(err.errors).map(e => e.message) });
  }
  if (err.name === 'JsonWebTokenError') {            // jsonwebtoken
    return res.status(401).json({ error: 'Invalid token' });
  }
  if (err.code === 11000) {                          // MongoDB duplicate key
    return res.status(409).json({ error: 'Duplicate entry' });
  }
  res.status(err.status || 500).json({ error: err.message || 'Server error' });
});

Discussion

  • Be the first to comment on this lesson.