nodejs|April 02, 2026|4 min read

Error Handling and Logging in Production Node.js

TL;DR

Create a custom error hierarchy (AppError → NotFoundError, ValidationError). Use centralized Express error middleware. Log structured JSON with Pino or Winston. Add correlation IDs for request tracing. Never expose stack traces in production.

Error Handling Strategy

Production error handling has two goals: give clients useful error responses and give your team actionable debugging information.

Error Handling Flow

Custom Error Classes

class AppError extends Error {
  constructor(message, statusCode, code) {
    super(message);
    this.statusCode = statusCode;
    this.code = code;
    this.isOperational = true; // Expected errors (not bugs)
    Error.captureStackTrace(this, this.constructor);
  }
}

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

class ValidationError extends AppError {
  constructor(details) {
    super('Validation failed', 400, 'VALIDATION_ERROR');
    this.details = details;
  }
}

class AuthenticationError extends AppError {
  constructor(message = 'Authentication required') {
    super(message, 401, 'AUTH_REQUIRED');
  }
}

class ForbiddenError extends AppError {
  constructor(message = 'Insufficient permissions') {
    super(message, 403, 'FORBIDDEN');
  }
}

class ConflictError extends AppError {
  constructor(message = 'Resource already exists') {
    super(message, 409, 'CONFLICT');
  }
}

module.exports = { AppError, NotFoundError, ValidationError, AuthenticationError, ForbiddenError, ConflictError };

Using Custom Errors in Routes

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

async function getUser(req, res) {
  const user = await db.users.findById(req.params.id);
  if (!user) {
    throw new NotFoundError('User');
  }
  res.json(user);
}

async function createUser(req, res) {
  const existing = await db.users.findByEmail(req.body.email);
  if (existing) {
    throw new ConflictError('Email already registered');
  }
  const user = await db.users.create(req.body);
  res.status(201).json(user);
}

Async Error Handling

Express doesn’t catch errors from async functions by default. Use a wrapper.

// Option 1: Wrapper function
function asyncHandler(fn) {
  return (req, res, next) => {
    Promise.resolve(fn(req, res, next)).catch(next);
  };
}

router.get('/users/:id', asyncHandler(async (req, res) => {
  const user = await db.users.findById(req.params.id);
  if (!user) throw new NotFoundError('User');
  res.json(user);
}));

// Option 2: express-async-errors (patches Express globally)
require('express-async-errors');

// Now async errors are automatically forwarded to error middleware
router.get('/users/:id', async (req, res) => {
  const user = await db.users.findById(req.params.id);
  if (!user) throw new NotFoundError('User');
  res.json(user);
});

Centralized Error Middleware

function errorHandler(err, req, res, next) {
  // Log the error
  if (err.isOperational) {
    req.log.warn({ err, requestId: req.id }, err.message);
  } else {
    req.log.error({ err, requestId: req.id }, 'Unexpected error');
  }

  // Determine response
  const statusCode = err.statusCode || 500;
  const response = {
    error: {
      code: err.code || 'INTERNAL_ERROR',
      message: err.isOperational ? err.message : 'Something went wrong',
      ...(err.details && { details: err.details }),
      ...(process.env.NODE_ENV !== 'production' && { stack: err.stack }),
    },
    requestId: req.id,
  };

  res.status(statusCode).json(response);
}

// Must be registered AFTER all routes
app.use(errorHandler);

Unhandled Rejections and Uncaught Exceptions

process.on('unhandledRejection', (reason, promise) => {
  logger.fatal({ reason }, 'Unhandled Promise Rejection');
  // In production, crash and let process manager restart
  process.exit(1);
});

process.on('uncaughtException', (error) => {
  logger.fatal({ error }, 'Uncaught Exception');
  // Give logger time to flush, then exit
  setTimeout(() => process.exit(1), 1000);
});

// Graceful shutdown on SIGTERM
process.on('SIGTERM', async () => {
  logger.info('SIGTERM received, shutting down...');
  server.close(async () => {
    await db.close();
    process.exit(0);
  });
  // Force exit after 10s
  setTimeout(() => process.exit(1), 10000);
});

Structured Logging with Pino

Pino is the fastest Node.js logger — 5x faster than Winston for JSON output.

Logging Pipeline

const pino = require('pino');

const logger = pino({
  level: process.env.LOG_LEVEL || 'info',
  formatters: {
    level(label) {
      return { level: label }; // Use string labels, not numbers
    },
  },
  serializers: {
    err: pino.stdSerializers.err,
    req: (req) => ({
      method: req.method,
      url: req.url,
      remoteAddress: req.ip,
    }),
  },
  // Pretty print in development
  ...(process.env.NODE_ENV !== 'production' && {
    transport: {
      target: 'pino-pretty',
      options: { colorize: true, translateTime: 'SYS:standard' },
    },
  }),
});

Request Logging with Correlation IDs

const { v4: uuid } = require('uuid');

// Attach request ID and child logger to every request
app.use((req, res, next) => {
  req.id = req.headers['x-request-id'] || uuid();
  req.log = logger.child({ requestId: req.id });
  res.setHeader('x-request-id', req.id);
  next();
});

// Request/Response logging
app.use((req, res, next) => {
  const start = Date.now();

  res.on('finish', () => {
    const duration = Date.now() - start;
    const logData = {
      method: req.method,
      url: req.originalUrl,
      status: res.statusCode,
      duration,
      contentLength: res.getHeader('content-length'),
    };

    if (res.statusCode >= 500) {
      req.log.error(logData, 'request error');
    } else if (res.statusCode >= 400) {
      req.log.warn(logData, 'request warning');
    } else {
      req.log.info(logData, 'request completed');
    }
  });

  next();
});

Winston Alternative

const winston = require('winston');

const logger = winston.createLogger({
  level: 'info',
  format: winston.format.combine(
    winston.format.timestamp(),
    winston.format.errors({ stack: true }),
    winston.format.json(),
  ),
  defaultMeta: { service: 'api-service' },
  transports: [
    new winston.transports.Console({
      format: process.env.NODE_ENV !== 'production'
        ? winston.format.combine(winston.format.colorize(), winston.format.simple())
        : winston.format.json(),
    }),
    // File transport for errors
    new winston.transports.File({
      filename: 'logs/error.log',
      level: 'error',
      maxsize: 10 * 1024 * 1024, // 10MB
      maxFiles: 5,
    }),
  ],
});

Log Levels Guide

Level When to Use Example
fatal App is about to crash Uncaught exception, out of memory
error Operation failed, needs attention Database connection failed, payment failed
warn Unexpected but handled Rate limit hit, deprecated API used
info Normal operations Request completed, user logged in
debug Development diagnostics SQL query, cache hit/miss
trace Granular debugging Function entry/exit, variable values

Production Logging Checklist

  1. Always log structured JSON — parseable by log aggregators
  2. Include correlation IDs — trace requests across services
  3. Never log sensitive data — passwords, tokens, PII
  4. Set appropriate log levelsinfo in production, debug in development
  5. Use child loggers — inherit context (requestId, userId)
  6. Rotate logs — use file size limits or external log shipping
  7. Alert on error rate spikes — integrate with monitoring tools
// NEVER log this
logger.info({ password: user.password }); // Leaks credentials

// Log this instead
logger.info({ userId: user.id, email: user.email }, 'User created');

Related Posts

Latest Posts