Node.js Error Handling Best Practices 2026: Complete Guide

Node.js Error Handling Best Practices 2026: Complete Guide

Node.js applications fail silently more often than developers realize. Unhandled promise rejections, swallowed callback errors, and missing global safety nets crash production servers without a single log line — and this guide shows you exactly how to stop that from happening.

Understanding Node.js Error Types and Stack Traces

Close-up of a computer screen displaying an authentication failed message.
Photo by Markus Spiske on Pexels

Operational vs. Programmer Errors: When to Recover vs. When to Crash

Most error handling mistakes come from treating all errors the same. There are two distinct categories, and confusing them leads to wrong recovery strategies.

Operational errors are expected runtime failures: network timeouts, invalid user input, database connection drops. Recover from these gracefully. Programmer errors are bugs: null dereferences, wrong argument types, logic failures. Don't try to recover — crash and fix the code.

// WRONG: Treating a programmer error like an operational one
function getUserById(id) {
  try {
    return users[id].profile; // TypeError if users[id] is undefined
  } catch (err) {
    return null; // Silently swallowing a bug
  }
}

// RIGHT: Validate input (operational), let programmer errors surface
function getUserById(id) {
  if (!id || typeof id !== 'string') {
    throw new ValidationError('Invalid user ID format', { id });
  }
  const user = users[id];
  if (!user) {
    throw new NotFoundError('User not found', { id });
  }
  return user.profile;
}

Reading Node.js Error Objects and Stack Traces

The built-in Error object gives you message, stack, name, and for system errors, code and errno. The stack trace is your fastest path to the bug — but only if you enrich it with context.

class AppError extends Error {
  constructor(message, options = {}) {
    super(message);
    this.name = this.constructor.name;
    this.code = options.code || 'INTERNAL_ERROR';
    this.statusCode = options.statusCode || 500;
    this.context = options.context || {};
    this.isOperational = options.isOperational ?? true;
    Error.captureStackTrace(this, this.constructor);
  }

  toJSON() {
    return {
      name: this.name,
      message: this.message,
      code: this.code,
      context: this.context,
    };
  }
}

class ValidationError extends AppError {
  constructor(message, context) {
    super(message, { code: 'VALIDATION_ERROR', statusCode: 400, context });
  }
}

class NotFoundError extends AppError {
  constructor(message, context) {
    super(message, { code: 'NOT_FOUND', statusCode: 404, context });
  }
}

Global Safety Nets: Uncaught Exceptions and Unhandled Rejections

These handlers are your last line of defense, not your primary strategy. Use them to log the error, notify monitoring, and shut down cleanly — never to continue running a corrupted process.

// WRONG: Trying to keep the server alive after an uncaught exception
process.on('uncaughtException', (err) => {
  console.error('Something went wrong, continuing...', err);
});

// RIGHT: Log, notify, degrade health check, then exit
process.on('uncaughtException', (err) => {
  logger.fatal({ err }, 'Uncaught exception — shutting down');
  healthCheck.setUnhealthy();
  // Give logger time to flush, then exit
  setTimeout(() => process.exit(1), 500);
});

process.on('unhandledRejection', (reason, promise) => {
  logger.error({ reason, promise }, 'Unhandled promise rejection');
  // In Node.js 18+, this exits automatically — match that behavior
  process.exit(1);
});

Modern Async Error Handling Patterns

Try-Catch with Async/Await: Gotchas That Bite in Production

The most common mistake is wrapping everything in one giant try-catch and losing the ability to differentiate error types.

// WRONG: One catch block, no discrimination
async function createOrder(userId, items) {
  try {
    const user = await db.findUser(userId);
    const inventory = await db.checkInventory(items);
    const order = await db.createOrder(user, inventory);
    await emailService.sendConfirmation(order);
    return order;
  } catch (err) {
    // Is this a DB error? Email error? Validation error? No idea.
    res.status(500).json({ error: 'Something failed' });
  }
}

// RIGHT: Granular handling with finally for cleanup
async function createOrder(userId, items) {
  let order = null;
  try {
    const user = await db.findUser(userId);
    const inventory = await db.checkInventory(items);
    order = await db.createOrder(user, inventory);
  } catch (err) {
    if (err instanceof NotFoundError) throw err;
    if (err.code === 'INSUFFICIENT_INVENTORY') {
      throw new AppError('Items unavailable', { statusCode: 409, code: 'INVENTORY_ERROR' });
    }
    throw new AppError('Order creation failed', { statusCode: 500, context: { userId } });
  } finally {
    // Cleanup runs whether or not an error occurred
    await db.releaseConnection();
  }

  // Email failure shouldn't fail the order — handle separately
  emailService.sendConfirmation(order).catch(err =>
    logger.warn({ err, orderId: order.id }, 'Confirmation email failed')
  );

  return order;
}

Promise Chains and Promise.allSettled() for Partial Failures

Promise.all() fails fast — one rejection kills the whole batch. Promise.allSettled() is the 2026 standard for operations where partial success is acceptable.

// WRONG: Promise.all() — one failure loses all results
const results = await Promise.all(userIds.map(id => fetchUser(id)));

// RIGHT: Promise.allSettled() — inspect each result individually
const settlements = await Promise.allSettled(userIds.map(id => fetchUser(id)));

const users = [];
const failures = [];

for (const [index, result] of settlements.entries()) {
  if (result.status === 'fulfilled') {
    users.push(result.value);
  } else {
    failures.push({ userId: userIds[index], error: result.reason.message });
    logger.warn({ userId: userIds[index], err: result.reason }, 'User fetch failed');
  }
}

return { users, failures };

Modernizing Callback-Based APIs

Legacy Node.js code uses error-first callbacks. Don't rewrite everything — use util.promisify to wrap them and handle errors in one consistent style.

const { promisify } = require('util');
const fs = require('fs');

// Legacy callback style
fs.readFile('config.json', 'utf8', (err, data) => {
  if (err) return handleError(err);
  processData(data);
});

// Promisified — now works with async/await
const readFile = promisify(fs.readFile);

async function loadConfig() {
  try {
    const data = await readFile('config.json', 'utf8');
    return JSON.parse(data);
  } catch (err) {
    if (err.code === 'ENOENT') {
      logger.warn('Config file missing, using defaults');
      return DEFAULT_CONFIG;
    }
    throw err;
  }
}

For modern code, prefer fs.promises directly. See the Node.js official fs.promises docs for the full API.

Structured Logging and Error Monitoring

Close-up of PHP code on a monitor, highlighting development and programming concepts.
Photo by Pixabay on Pexels

Structured Logging with Pino and Correlation IDs

Plain-text logs are useless at scale. JSON structured logs let you query, filter, and correlate across distributed services. Correlation IDs are non-negotiable for microservices debugging.

const pino = require('pino');
const { randomUUID } = require('crypto');

const logger = pino({
  level: process.env.LOG_LEVEL || 'info',
  formatters: {
    level: (label) => ({ level: label }),
  },
});

// Express middleware: inject correlation ID into every request
function correlationMiddleware(req, res, next) {
  req.correlationId = req.headers['x-correlation-id'] || randomUUID();
  req.log = logger.child({ correlationId: req.correlationId, path: req.path });
  res.setHeader('x-correlation-id', req.correlationId);
  next();
}

// Log errors with full context
function logError(err, req) {
  const logData = {
    err: { message: err.message, code: err.code, stack: err.stack },
    correlationId: req?.correlationId,
    userId: req?.user?.id,
    context: err.context || {},
  };

  if (err.isOperational) {
    logger.warn(logData, 'Operational error');
  } else {
    logger.error(logData, 'Unexpected error');
  }
}

Error Monitoring with Sentry and Error Budgets

Logging tells you what happened. Monitoring tells you how often and whether it's getting worse. Error budgets — borrowed from SRE practice — define how much failure is acceptable before paging someone at 2am.

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

Sentry.init({
  dsn: process.env.SENTRY_DSN,
  environment: process.env.NODE_ENV,
  tracesSampleRate: 0.2, // Sample 20% of transactions
  beforeSend(event, hint) {
    const err = hint.originalException;
    // Don't report expected operational errors to reduce noise
    if (err?.isOperational && err?.statusCode < 500) return null;
    return event;
  },
});

// Enrich Sentry events with business context
function captureError(err, context = {}) {
  Sentry.withScope((scope) => {
    scope.setTag('error_code', err.code);
    scope.setContext('business_context', context);
    if (context.userId) scope.setUser({ id: context.userId });
    Sentry.captureException(err);
  });
}

Resilience Patterns and Express Middleware

White letter tiles spelling 'ERROR' on a red backdrop, offering a minimalist design concept.
Photo by Miguel Á. Padriñán on Pexels

Retry Logic with Exponential Backoff

Transient failures — database blips, third-party API hiccups — are operational errors you should retry. Retry immediately once, then back off exponentially with jitter to avoid thundering herds.

async function withRetry(fn, options = {}) {
  const { maxAttempts = 3, baseDelayMs = 100, shouldRetry } = options;

  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
    try {
      return await fn();
    } catch (err) {
      const isLastAttempt = attempt === maxAttempts;
      const retryable = shouldRetry ? shouldRetry(err) : err.isOperational;

      if (isLastAttempt || !retryable) throw err;

      const jitter = Math.random() * 100;
      const delay = baseDelayMs * Math.pow(2, attempt - 1) + jitter;

      logger.warn({ attempt, delay, err: err.message }, 'Retrying after failure');
      await new Promise(resolve => setTimeout(resolve, delay));
    }
  }
}

// Usage
const user = await withRetry(() => db.findUser(userId), {
  maxAttempts: 3,
  shouldRetry: (err) => err.code === 'ECONNRESET' || err.code === 'ETIMEDOUT',
});

Express Error Handling Middleware

Express error middleware must have exactly four parameters. One centralized handler beats scattered res.status(500) calls across every route. See our guide on Express middleware patterns for more on structuring middleware stacks.

// Must be registered AFTER all routes
function globalErrorHandler(err, req, res, next) {
  logError(err, req);

  if (!err.isOperational) {
    captureError(err, { userId: req.user?.id, correlationId: req.correlationId });
  }

  const statusCode = err.statusCode || 500;

  // Never expose stack traces or internal details to clients
  const response = {
    error: {
      code: err.isOperational ? err.code : 'INTERNAL_ERROR',
      message: err.isOperational ? err.message : 'An unexpected error occurred',
      correlationId: req.correlationId,
    },
  };

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

// Async route wrapper — eliminates try-catch boilerplate in every route
const asyncRoute = (fn) => (req, res, next) =>
  Promise.resolve(fn(req, res, next)).catch(next);

// Usage
router.get('/users/:id', asyncRoute(async (req, res) => {
  const user = await getUserById(req.params.id);
  res.json(user);
}));

app.use(globalErrorHandler);

For deep dives into production patterns, the Express.js official error handling guide covers the middleware execution order in detail.

Pattern Best For Avoid When
try-catch + async/await Sequential async operations, granular error types Fire-and-forget side effects
Promise.allSettled() Batch operations, partial failure acceptable All-or-nothing transactions
Retry + backoff Transient network/DB failures Validation errors, auth failures
Global Express middleware Centralized HTTP error responses Internal service-to-service calls
process.on uncaughtException Last-resort logging before crash Recovering and continuing execution

Also check out our Node.js performance patterns post — error handling overhead is a real consideration at scale.

Frequently Asked Questions

Q: Should I use try-catch or .catch() with async/await?

A: Use try-catch for async/await — mixing .catch() on awaited calls creates confusing control flow. The exception is fire-and-forget side effects like sending analytics, where you want to catch errors without blocking: trackEvent().catch(logger.warn).

Q: When should process.exit(1) be called after an uncaught exception?

A: Always, after a short flush delay. Running a Node.js process with corrupted state is worse than restarting it. In container environments, your orchestrator (Kubernetes, ECS) will restart the container automatically — that's the correct recovery mechanism.

Q: How do I handle errors in Express middleware that calls async code?

A: Wrap every async route handler with the asyncRoute utility shown above, or use a library like express-async-errors that patches Express internally. Without it, rejected promises in route handlers are silently swallowed and never reach your error middleware.

Wrap-up

Good error handling in Node.js is a stack: custom error classes give you structure, async patterns prevent silent failures, structured logging makes incidents debuggable, and resilience patterns keep transient failures from becoming outages. Start by adding the asyncRoute wrapper and the global Express error handler to your existing codebase — those two changes eliminate the most common production failure modes immediately.

Comments

Popular posts from this blog

PostgreSQL Indexing Guide for Faster Queries (2026)

JavaScript Promises vs Async Await: Complete Guide (2026)