arrow_backBACK TO NODE.JS BACKEND ENGINEERING
Lesson 10Node.js Backend Engineering7 min read

Error Handling and Logging in Production

April 03, 2026

TL;DR

Create a custom error hierarchy with HTTP status codes and operational vs programmer error distinction. Use async error boundaries in Express. Log structured JSON with Pino for performance or Winston for flexibility. Add correlation IDs to trace requests across services. Send errors to Sentry for alerting.

A production Node.js application that swallows errors silently or logs unstructured text to stdout is a ticking time bomb. When something breaks at 3 AM, you need to know exactly what failed, which request triggered it, and whether the error is something your code can recover from or a fundamental bug that needs a deploy. This lesson covers the full error handling and logging pipeline — from throwing structured errors to getting an alert on your phone.

Custom Error Classes

The built-in Error class tells you almost nothing useful for API responses. You need errors that carry HTTP status codes, error codes for clients, and a flag that distinguishes operational errors (expected failures) from programmer errors (bugs).

class AppError extends Error {
  constructor(message, statusCode, errorCode, isOperational = true) {
    super(message);
    this.name = this.constructor.name;
    this.statusCode = statusCode;
    this.errorCode = errorCode;
    this.isOperational = isOperational;
    Error.captureStackTrace(this, this.constructor);
  }
}

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

class ValidationError extends AppError {
  constructor(message, details = []) {
    super(message, 400, 'VALIDATION_ERROR');
    this.details = details;
  }
}

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

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');
  }
}

Every error your application throws should extend AppError. This gives you a single place to check instanceof AppError in your error handler and know the error is something your code anticipated.

Operational vs Programmer Errors

This distinction is the most important concept in Node.js error handling:

Operational errors are expected failures. A user submits invalid input, a database query times out, a third-party API returns 503. Your code anticipated these. You log them, send a clean response, and move on. The isOperational flag is true.

Programmer errors are bugs. You access a property on undefined, pass the wrong type to a function, or have a logic error. These are unexpected. The isOperational flag is false (or the error is not an AppError at all).

The difference matters for recovery strategy. Operational errors: handle and continue. Programmer errors: log, alert, and potentially restart the process because the application state may be corrupted.

function handleError(error) {
  if (error.isOperational) {
    // Log and continue — the app is fine
    logger.warn({ err: error }, error.message);
    return;
  }

  // Programmer error — the app may be in a bad state
  logger.fatal({ err: error }, 'Programmer error detected');
  // Alert on-call, consider graceful shutdown
  process.exit(1);
}

Express Async Error Handling

Express does not catch errors thrown inside async route handlers. If you await something that rejects and you do not have a try/catch, the error vanishes and the request hangs until timeout.

The simplest fix is the express-async-errors package. Import it once at the top of your app and Express will catch async errors automatically:

import 'express-async-errors';
import express from 'express';

const app = express();

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

If you prefer not to add a dependency, write a wrapper:

const asyncHandler = (fn) => (req, res, next) => {
  Promise.resolve(fn(req, res, next)).catch(next);
};

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

Both approaches forward rejected promises to Express’s error handling middleware.

Global Error Handler Middleware

Every Express app needs a centralized error handler. This is the single place where all errors are formatted, logged, and sent as responses.

Error handling flow

function errorHandler(err, req, res, next) {
  // Default to 500 for unexpected errors
  const statusCode = err.statusCode || 500;
  const errorCode = err.errorCode || 'INTERNAL_ERROR';
  const message = err.isOperational
    ? err.message
    : 'An unexpected error occurred';

  // Log the error
  const logPayload = {
    err,
    requestId: req.id,
    method: req.method,
    url: req.originalUrl,
    statusCode,
  };

  if (statusCode >= 500) {
    logger.error(logPayload, err.message);
  } else {
    logger.warn(logPayload, err.message);
  }

  // Send structured response
  res.status(statusCode).json({
    error: {
      code: errorCode,
      message,
      ...(err.details && { details: err.details }),
      ...(process.env.NODE_ENV === 'development' && {
        stack: err.stack,
      }),
    },
  });

  // Report to Sentry for 5xx errors
  if (statusCode >= 500) {
    Sentry.captureException(err);
  }
}

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

Notice that the error handler never exposes internal error messages for 500 errors. Leaking stack traces or database error details to clients is a security risk.

Structured Logging with Pino and Winston

Unstructured log lines like console.log('User created:', userId) are nearly impossible to search and aggregate. Production logging must be structured JSON.

Pino — Fast and Lightweight

Pino is the fastest Node.js logger. It writes JSON to stdout and lets your deployment infrastructure (Docker, Kubernetes, CloudWatch) handle transport.

import pino from 'pino';

const logger = pino({
  level: process.env.LOG_LEVEL || 'info',
  formatters: {
    level(label) {
      return { level: label };
    },
  },
  timestamp: pino.stdTimeFunctions.isoTime,
  redact: ['req.headers.authorization', 'req.body.password'],
});

// Usage
logger.info({ userId: '123', action: 'signup' }, 'User created');
// Output: {"level":"info","time":"2026-04-03T...","userId":"123","action":"signup","msg":"User created"}

Winston — Flexible Transports

Winston is better if you need multiple transports (file, HTTP, database) or custom formatting:

import winston from 'winston';

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: 'user-service' },
  transports: [
    new winston.transports.Console(),
    new winston.transports.File({
      filename: 'logs/error.log',
      level: 'error',
    }),
  ],
});

For most production workloads, Pino is the better choice. It is 5-10x faster than Winston because it defers serialization and writes asynchronously.

Log Levels and When to Use Each

Level When to Use Example
fatal App is about to crash Unhandled exception, out of memory
error Operation failed, needs attention Database connection lost, payment failed
warn Something unexpected but recoverable Deprecated API used, retry succeeded
info Normal operations worth recording Server started, user signed up, order placed
debug Detailed diagnostic info SQL queries, cache hits/misses, request payloads
trace Extremely verbose, rarely used Function entry/exit, variable values

Production should run at info level. Drop to debug temporarily when investigating issues. Never run trace in production — the volume will overwhelm your log pipeline.

Correlation IDs for Request Tracing

When a single user request touches multiple services, you need a way to trace it. A correlation ID (also called request ID or trace ID) is a unique identifier attached to every log line for a given request.

Logging architecture

import { randomUUID } from 'crypto';
import { AsyncLocalStorage } from 'async_hooks';

const asyncLocalStorage = new AsyncLocalStorage();

// Middleware to create/propagate correlation ID
function correlationMiddleware(req, res, next) {
  const correlationId =
    req.headers['x-correlation-id'] || randomUUID();
  req.id = correlationId;
  res.setHeader('x-correlation-id', correlationId);

  asyncLocalStorage.run({ correlationId }, () => next());
}

// Logger that automatically includes correlation ID
function getLogger() {
  const store = asyncLocalStorage.getStore();
  return logger.child({
    correlationId: store?.correlationId,
  });
}

// Usage in any service
function findUser(id) {
  const log = getLogger();
  log.info({ userId: id }, 'Looking up user');
  // Every log line automatically includes correlationId
}

AsyncLocalStorage is the key technology here. It propagates context through the entire async call chain without manually passing a logger or request ID through every function.

Error Reporting with Sentry

Logging captures the details. Sentry captures the context, groups similar errors, tracks frequency, and sends alerts.

import * as Sentry from '@sentry/node';

Sentry.init({
  dsn: process.env.SENTRY_DSN,
  environment: process.env.NODE_ENV,
  tracesSampleRate: 0.1, // 10% of transactions
  beforeSend(event) {
    // Scrub sensitive data
    if (event.request?.headers) {
      delete event.request.headers['authorization'];
    }
    return event;
  },
});

// Express integration
app.use(Sentry.Handlers.requestHandler());
app.use(Sentry.Handlers.tracingHandler());

// Routes go here...

// Sentry error handler BEFORE your error handler
app.use(Sentry.Handlers.errorHandler());
app.use(errorHandler);

Sentry groups errors by stack trace, so the same bug from 1,000 different requests shows up as a single issue with a count. You can set alert rules: “If this new error happens more than 10 times in 5 minutes, page the on-call engineer.”

Uncaught Exception and Unhandled Rejection Handlers

These are your last line of defense. If an error escapes all try/catch blocks and middleware, these global handlers catch it before the process dies silently.

process.on('uncaughtException', (error) => {
  logger.fatal({ err: error }, 'Uncaught exception');
  Sentry.captureException(error);

  // Give Sentry time to flush, then exit
  setTimeout(() => process.exit(1), 1000);
});

process.on('unhandledRejection', (reason, promise) => {
  logger.fatal({ err: reason }, 'Unhandled rejection');
  Sentry.captureException(reason);

  // Treat unhandled rejections as fatal
  setTimeout(() => process.exit(1), 1000);
});

Starting with Node.js 15, unhandled rejections crash the process by default. This is the correct behavior — an unhandled rejection means your error handling has a gap. Fix the gap rather than suppressing the crash.

Always exit after an uncaught exception. The process state is unknown and continuing could cause data corruption. Your process manager (PM2, systemd, Kubernetes) will restart the process automatically.

Logging Best Practices

What to log:

  • Request method, URL, status code, duration for every request
  • Business events: user signup, order placed, payment processed
  • Error details with full stack traces
  • External service calls with latency
  • Database query timing (at debug level)

What NOT to log:

  • Passwords, tokens, API keys, credit card numbers
  • Full request/response bodies (use Pino’s redact option)
  • Personal data that violates GDPR/CCPA
  • Health check requests (they pollute logs with noise)

Formatting rules:

  • Always use structured JSON, never string interpolation
  • Include timestamps in ISO 8601 format
  • Add service name and version to every log line
  • Use consistent field names (userId, not sometimes user_id and sometimes uid)

Operational rules:

  • Set log level via environment variable, not code changes
  • Rotate log files or use stdout with a log aggregator
  • Set up alerts on error rate spikes, not individual errors
  • Keep logs for 30-90 days depending on compliance requirements

Putting It All Together

A production-ready Express application wires these pieces together at startup:

import 'express-async-errors';
import express from 'express';
import * as Sentry from '@sentry/node';
import { correlationMiddleware } from './middleware/correlation.js';
import { errorHandler } from './middleware/error-handler.js';
import { requestLogger } from './middleware/request-logger.js';

Sentry.init({ dsn: process.env.SENTRY_DSN });

const app = express();

app.use(Sentry.Handlers.requestHandler());
app.use(correlationMiddleware);
app.use(requestLogger);

// ... routes

app.use(Sentry.Handlers.errorHandler());
app.use(errorHandler);

// Global handlers
process.on('uncaughtException', /* ... */);
process.on('unhandledRejection', /* ... */);

The order matters. Correlation middleware runs first so every subsequent log has a request ID. The request logger records start time. Routes throw structured errors. Sentry captures them. Your error handler logs and responds. Every log line is JSON with a correlation ID that ties the entire request lifecycle together.

This is what production error handling looks like. Not a try/catch around every function — a pipeline that catches, classifies, logs, responds, and alerts in a single coherent flow.