Skip to main content

Error Handling and Logging in Node.js

Published: May 24, 2026 Updated: August 30, 2026 Larry Qu 8 min read

Good error handling isn’t defensive programming — it’s information design. Every unhandled error in production is a bug in your error strategy: either you didn’t anticipate the failure, didn’t log enough context to debug it, or didn’t communicate it clearly to clients. Building a solid error and logging foundation early prevents an entire class of production incidents.

Designing an Error Hierarchy

The first step is distinguishing operational errors from programmer errors. This distinction matters because they require different responses:

Operational errors are expected failures: network timeouts, validation failures, resource not found, authentication required. Handle them explicitly, return appropriate HTTP status codes, and log at an appropriate level.

Programmer errors are bugs: null pointer dereferences, type mismatches, assertion failures. Let the process crash (or restart via a process manager), and capture the full stack trace for debugging.

Build a typed error hierarchy to represent operational errors:

// src/errors/index.js

class AppError extends Error {
  constructor(message, { status = 500, code = null, details = null } = {}) {
    super(message);
    this.name = this.constructor.name;
    this.status = status;
    this.code   = code;
    this.details = details;
    this.isOperational = true;   // Marks this as a handled error, not a bug
    Error.captureStackTrace(this, this.constructor);
  }
}

class ValidationError extends AppError {
  constructor(message, details = null) {
    super(message, { status: 400, code: 'VALIDATION_ERROR', details });
  }
}

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

class UnauthorizedError extends AppError {
  constructor(message = 'Authentication required') {
    super(message, { status: 401, code: 'UNAUTHORIZED' });
  }
}

class ForbiddenError extends AppError {
  constructor(message = 'Access denied') {
    super(message, { status: 403, code: 'FORBIDDEN' });
  }
}

class ConflictError extends AppError {
  constructor(message, code = 'CONFLICT') {
    super(message, { status: 409, code });
  }
}

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

Using named error classes means instanceof checks work correctly, and stack traces show the specific error type in the first line.

Express Error Handling Middleware

Express’s four-argument error handler catches everything passed to next(err):

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

const errorHandler = (err, req, res, next) => {
  // Attach request context to every error log
  const context = {
    requestId: req.id,
    method: req.method,
    url: req.originalUrl,
    userId: req.user?.id,
  };

  // Operational errors are expected — log at warn level, return structured response
  if (err.isOperational) {
    logger.warn({ msg: err.message, code: err.code, ...context });
    return res.status(err.status).json({
      success: false,
      error: {
        message: err.message,
        code: err.code,
        ...(err.details && { details: err.details }),
      },
    });
  }

  // Handle framework-specific errors before treating them as bugs
  if (err.name === 'ValidationError') {
    // Mongoose model validation failed
    const details = Object.values(err.errors).map(e => ({ field: e.path, message: e.message }));
    logger.warn({ msg: 'Mongoose validation error', ...context });
    return res.status(400).json({
      success: false,
      error: { message: 'Validation failed', code: 'VALIDATION_ERROR', details }
    });
  }

  if (err.name === 'CastError') {
    return res.status(400).json({ success: false, error: { message: 'Invalid ID format' } });
  }

  if (err.code === 11000) {
    // MongoDB duplicate key
    const field = Object.keys(err.keyValue ?? {})[0] ?? 'field';
    logger.warn({ msg: 'Duplicate key', field, ...context });
    return res.status(409).json({
      success: false,
      error: { message: `${field} already in use`, code: 'DUPLICATE_KEY' }
    });
  }

  // Programmer error — log full stack trace and return generic 500
  logger.error({ msg: err.message, stack: err.stack, ...context });
  return res.status(500).json({
    success: false,
    error: { message: 'Internal server error' }
  });
};

// Wrap async route handlers — passes any rejected promise to the error handler
const asyncHandler = (fn) => (req, res, next) =>
  Promise.resolve(fn(req, res, next)).catch(next);

module.exports = { errorHandler, asyncHandler };

Use the asyncHandler wrapper on every async route to avoid unhandled promise rejections:

const { asyncHandler } = require('../middleware/errorHandler');
const { NotFoundError } = require('../errors');
const User = require('../models/User');

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

Structured Logging with Winston

Plain console.log output is hard to search in production. Structured logging emits JSON objects, which log aggregators (Datadog, CloudWatch, Elastic) can index and query — you can filter by userId, requestId, or errorCode directly.

Install Winston:

npm install winston

Configure a logger with appropriate transports for each environment:

// src/lib/logger.js
const winston = require('winston');

const { combine, timestamp, json, colorize, simple, errors } = winston.format;

// Base formats — JSON with timestamps and proper error serialization
const productionFormat = combine(
  timestamp(),
  errors({ stack: true }),   // Include stack traces in error logs
  json()
);

// Human-readable format for local development
const developmentFormat = combine(
  colorize(),
  timestamp({ format: 'HH:mm:ss' }),
  errors({ stack: true }),
  simple()
);

const logger = winston.createLogger({
  level: process.env.LOG_LEVEL || 'info',
  format: process.env.NODE_ENV === 'production' ? productionFormat : developmentFormat,
  defaultMeta: {
    service: process.env.SERVICE_NAME || 'api',
    env: process.env.NODE_ENV || 'development',
  },
  transports: [
    new winston.transports.Console(),
    // In production, also write errors to a persistent file
    ...(process.env.NODE_ENV === 'production' ? [
      new winston.transports.File({ filename: 'logs/error.log', level: 'error' }),
    ] : [])
  ],
  // Don't crash the process if a logger transport fails
  exitOnError: false,
});

module.exports = logger;

Structured log output in production:

{
  "level": "warn",
  "msg": "User not found",
  "code": "NOT_FOUND",
  "requestId": "req-abc123",
  "userId": "user-42",
  "method": "GET",
  "url": "/api/users/999",
  "service": "api",
  "env": "production",
  "timestamp": "2026-08-30T14:22:11.453Z"
}

Request logging middleware captures every inbound request with timing:

// src/middleware/requestLogger.js
const { v4: uuid } = require('uuid');
const logger = require('../lib/logger');

const requestLogger = (req, res, next) => {
  req.id    = uuid();
  req.start = Date.now();

  res.on('finish', () => {
    const duration = Date.now() - req.start;
    const level = res.statusCode >= 500 ? 'error'
                : res.statusCode >= 400 ? 'warn'
                : 'info';

    logger[level]({
      msg: 'HTTP request',
      requestId: req.id,
      method: req.method,
      url: req.originalUrl,
      status: res.statusCode,
      durationMs: duration,
      userId: req.user?.id,
    });
  });

  next();
};

module.exports = requestLogger;

Sentry Integration

Sentry captures uncaught exceptions and provides stack traces, request context, and a dashboard for triaging issues. In production, it’s invaluable for bugs that slip through error handling.

npm install @sentry/node

Initialize Sentry before any other imports (it patches Node.js internals):

// src/instrument.js — import this file first in your entry point
const Sentry = require('@sentry/node');

Sentry.init({
  dsn: process.env.SENTRY_DSN,
  environment: process.env.NODE_ENV,
  tracesSampleRate: process.env.NODE_ENV === 'production' ? 0.1 : 1.0,  // 10% of requests in prod
  beforeSend(event) {
    // Don't send events for operational errors — only bugs
    if (event.exception?.values?.[0]?.type && event.tags?.isOperational) {
      return null;
    }
    return event;
  },
});

module.exports = Sentry;
// src/server.js
require('./instrument');  // Must be first
const app = require('./app');
// ...

Add Sentry to your Express error handler — it needs to run before your custom handler:

// app.js
const Sentry = require('@sentry/node');

// Request handler must be the first middleware
app.use(Sentry.Handlers.requestHandler());

app.use('/api', routes);

// Error handler must come before your custom error handler
app.use(Sentry.Handlers.errorHandler({
  shouldHandleError(error) {
    // Only capture programmer errors, not operational errors
    return !error.isOperational;
  }
}));

app.use(errorHandler);  // Your custom error handler

Attach user context to Sentry events for easier debugging:

// In your auth middleware, after setting req.user:
Sentry.setUser({ id: req.user.id, email: req.user.email });

Health Check Endpoints

Health checks let load balancers and orchestrators know if your instance is ready to receive traffic. Kubernetes uses two types: liveness (is the process alive?) and readiness (can it serve traffic?).

// src/routes/health.js
const router = require('express').Router();
const mongoose = require('mongoose');

// Liveness check — is the process running and not deadlocked?
router.get('/live', (req, res) => {
  res.json({ status: 'ok', uptime: process.uptime() });
});

// Readiness check — can this instance actually serve requests?
router.get('/ready', async (req, res) => {
  const checks = {};
  let isReady = true;

  // Check database connectivity
  try {
    await mongoose.connection.db.command({ ping: 1 });
    checks.database = 'ok';
  } catch (err) {
    checks.database = 'error';
    isReady = false;
  }

  // Check cache (Redis) if applicable
  // try { await redisClient.ping(); checks.cache = 'ok'; }
  // catch { checks.cache = 'error'; isReady = false; }

  res.status(isReady ? 200 : 503).json({
    status: isReady ? 'ready' : 'not ready',
    checks,
    timestamp: new Date().toISOString(),
  });
});

module.exports = router;

Mount these at the app level, not under /api, and exclude them from authentication middleware.

Handling Unhandled Rejections and Exceptions

Some errors escape Express middleware — unhandled promise rejections and uncaught exceptions. Handle them at the process level:

// src/server.js
process.on('unhandledRejection', (reason, promise) => {
  logger.error({
    msg: 'Unhandled promise rejection',
    reason: reason instanceof Error ? reason.message : reason,
    stack: reason instanceof Error ? reason.stack : undefined,
  });
  // Give in-flight requests time to complete before exiting
  server.close(() => process.exit(1));
});

process.on('uncaughtException', (err) => {
  logger.error({ msg: 'Uncaught exception', error: err.message, stack: err.stack });
  // Uncaught exceptions leave the process in an undefined state — must exit
  process.exit(1);
});

// Graceful shutdown on SIGTERM (from Docker/Kubernetes)
process.on('SIGTERM', () => {
  logger.info('SIGTERM received, shutting down gracefully');
  server.close(() => {
    mongoose.connection.close();
    logger.info('Shutdown complete');
    process.exit(0);
  });
});

Use a process manager like PM2 or a Kubernetes Deployment to automatically restart after crashes.

Summary

A production-ready error and logging strategy has these components in place before you ship:

  • A typed error hierarchy that distinguishes operational errors from bugs
  • Express middleware that handles each error type appropriately and never leaks stack traces to clients
  • Structured JSON logging with request context on every log line
  • Sentry (or equivalent) capturing only programmer errors with stack traces and user context
  • Health check endpoints for /live and /ready that load balancers can probe
  • Process-level handlers for unhandled rejections and uncaught exceptions with graceful shutdown

Resources

Comments

👍 Was this article helpful?