DEV Community

Alex Chen
Alex Chen

Posted on

Error Handling in Node.js: Beyond Try/Catch (2026)

Error Handling in Node.js: Beyond Try/Catch (2026)

Try/catch is just the beginning. Real-world error handling is about resilience, observability, and graceful degradation.

The Error Handling Mindset

Bad error handling:
→ try/catch everything silently → errors disappear, bugs never found
→ console.error(err) and move on → logs nobody reads
→ crash the process → downtime, lost requests
→ Return generic "error" message → impossible to debug

Good error handling:
→ Categorize errors (operational vs programming vs external)
→ Each category gets different handling (retry, report, or fail gracefully)
→ Every error is logged with context (request ID, user, params)
→ Users get helpful messages; developers get diagnostic details
→ The system heals itself when possible
Enter fullscreen mode Exit fullscreen mode

Custom Error Classes

// Base application error
class AppError extends Error {
  constructor(message, { code = 'ERROR', statusCode = 500, details = null, cause = null } = {}) {
    super(message);
    this.name = this.constructor.name;
    this.code = code;              // Machine-readable error code
    this.statusCode = statusCode;  // HTTP status code
    this.details = details;        // Additional context
    this.cause = cause;            // Original error (error chaining)

    // Capture stack trace (but clean it for production)
    Error.captureStackTrace(this, this.constructor);
  }

  toJSON() {
    return {
      name: this.name,
      message: this.message,
      code: this.code,
      statusCode: this.statusCode,
      ...(this.details && { details: this.details }),
    };
  }
}

// Specific error types:
class ValidationError extends AppError {
  constructor(fields) {
    super('Validation failed', {
      code: 'VALIDATION_ERROR',
      statusCode: 422,
      details: fields, // [{ field: 'email', message: 'Invalid format' }]
    });
  }
}

class NotFoundError extends AppError {
  constructor(resource, id) {
    super(`${resource} not found`, {
      code: `${resource.toUpperCase()}_NOT_FOUND`,
      statusCode: 404,
      details: { resource, id },
    });
  }
}

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

class RateLimitError extends AppError {
  constructor(retryAfterSeconds) {
    super('Too many requests', {
      code: 'RATE_LIMITED',
      statusCode: 429,
      details: { retryAfter: retryAfterSeconds },
    });
  }
}

class ExternalServiceError extends AppError {
  constructor(service, originalError) {
    super(`${service} service unavailable`, {
      code: `${service.toUpperCase()}_SERVICE_ERROR`,
      statusCode: 502,
      cause: originalError,
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

Centralized Error Handler

// middleware/errorHandler.js — Global Express error handler

const logger = require('./logger');

function errorHandler(err, req, res, _next) {
  // Assign a unique ID to this error incident
  const incidentId = crypto.randomUUID();

  // Default values for unknown errors
  const statusCode = err.statusCode || 500;
  const code = err.code || 'INTERNAL_ERROR';

  // Log EVERY error with full context
  logger.error(`[Error ${incidentId}]`, {
    incidentId,
    code,
    message: err.message,
    stack: err.stack,
    statusCode,
    method: req.method,
    url: req.url,
    requestId: req.id,
    userId: req.user?.id,
    ip: req.ip,
    userAgent: req.get('user-agent'),
    body: req.method !== 'GET' ? sanitizeBody(req.body) : undefined,
    cause: err.cause?.message,
  });

  // Don't expose internal details in production
  const isProduction = process.env.NODE_ENV === 'production';

  const response = {
    error: {
      code,
      message: isProduction ? 'An internal error occurred' : err.message,
      ...(isProduction ? {} : { details: err.details }),
      incidentId, // Include so support can look it up!
    }
  };

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

// Body sanitizer (prevent logging passwords/tokens)
function sanitizeBody(body) {
  if (!body || typeof body !== 'object') return body;
  const sensitiveFields = ['password', 'token', 'secret', 'apiKey', 'api_key', 'creditCard'];
  const sanitized = { ...body };
  for (const field of sensitiveFields) {
    if (field in sanitized) sanitized[field] = '[REDACTED]';
  }
  return sanitized;
}

module.exports = errorHandler;

// In app.js (MUST be last middleware, after all routes):
app.use(errorHandler);
Enter fullscreen mode Exit fullscreen mode

Async Error Handling Patterns

// Pattern 1: Async handler wrapper (eliminates try/catch boilerplate)
// utils/asyncHandler.js
const asyncHandler = (fn) => (req, res, next) =>
  Promise.resolve(fn(req, res, next)).catch(next);

// Usage:
const getUser = asyncHandler(async (req, res) => {
  const user = await User.findById(req.params.id);
  if (!user) throw new NotFoundError('User', req.params.id);
  res.json({ data: user });
});
// Any thrown error automatically goes to errorHandler middleware!

// Pattern 2: Retry logic for transient failures
async function retry(fn, options = {}) {
  const {
    maxAttempts = 3,
    delay = 1000,        // Initial delay (ms)
    maxDelay = 30000,   // Max delay cap
    backoff = 2,         // Multiplier
    retryableErrors = ['ECONNREFUSED', 'ETIMEDOUT', 'ECONNRESET', '502', '503', '504'],
  } = options;

  let lastError;

  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
    try {
      return await fn();
    } catch (err) {
      lastError = err;

      const shouldRetry = retryableErrors.some(code => 
        err.message?.includes(code) || 
        err.code === code ||
        err.statusCode?.toString() === code
      );

      if (!shouldRetry || attempt === maxAttempts) throw err;

      const waitTime = Math.min(delay * Math.pow(backoff, attempt - 1), maxDelay);
      logger.warn(`Retry ${attempt}/${maxAttempts} for ${err.message} in ${waitTime}ms`);
      await new Promise(resolve => setTimeout(resolve, waitTime));
    }
  }
}

// Usage:
const data = await retry(() => fetchFromExternalAPI(id), {
  maxAttempts: 5,
  delay: 500,
});

// Pattern 3: Timeout wrapper
function withTimeout(promise, ms, errorMessage = 'Operation timed out') {
  return Promise.race([
    promise,
    new Promise((_, reject) =>
      setTimeout(() => reject(new AppError(errorMessage, { code: 'TIMEOUT', statusCode: 504 })), ms)
    ),
  ]);
}

// Usage:
const user = await withTimeout(
  db.users.findById(userId),
  5000, // 5 second timeout
  'Database query timed out'
);

// Pattern 4: Circuit breaker (prevent cascading failures)
class CircuitBreaker {
  constructor(name, options = {}) {
    this.name = name;
    this.failureThreshold = options.failureThreshold ?? 5;
    this.resetTimeout = options.resetTimeout ?? 60000; // 1 minute
    this.state = 'CLOSED'; // CLOSED (normal), OPEN (failing), HALF_OPEN (testing)
    this.failures = 0;
    this.lastFailureTime = null;
  }

  async execute(fn) {
    if (this.state === 'OPEN') {
      if (Date.now() - this.lastFailureTime > this.resetTimeout) {
        this.state = 'HALF_OPEN';
      } else {
        throw new AppError(`${this.name} circuit is open`, {
          code: 'CIRCUIT_OPEN',
          statusCode: 503,
        });
      }
    }

    try {
      const result = await fn();
      this.onSuccess();
      return result;
    } catch (err) {
      this.onFailure();
      throw err;
    }
  }

  onSuccess() {
    this.failures = 0;
    this.state = 'CLOSED';
  }

  onFailure() {
    this.failures++;
    this.lastFailureTime = Date.now();
    if (this.failures >= this.failureThreshold) {
      this.state = 'OPEN';
      logger.warn(`Circuit breaker "${this.name}" OPENED after ${this.failures} failures`);
    }
  }
}

// Usage:
const apiBreaker = new CircuitBreaker('ExternalAPI');
const result = await apiBreaker.execute(() => fetchFromAPI(data));
Enter fullscreen mode Exit fullscreen mode

Graceful Degradation Strategies

// When something fails, provide a reduced experience instead of crashing:

// Strategy 1: Fallback value
async function getUserPreferences(userId) {
  try {
    return await preferencesService.get(userId);
  } catch (err) {
    logger.warn(`Prefs service failed for ${userId}, using defaults`);
    return { theme: 'light', language: 'en' }; // Sensible defaults
  }
}

// Strategy 2: Cached stale data
async function getProductCatalog() {
  try {
    const fresh = await catalogService.fetch();
    await cache.set('catalog', fresh, { ttl: 3600 });
    return fresh;
  } catch (err) {
    logger.error('Catalog fetch failed, trying cache');
    const cached = await cache.get('catalog');
    if (cached) {
      res.setHeader('X-Cache-Status', 'stale');
      return cached;
    }
    throw new AppError('Catalog unavailable', { code: 'CATALOG_ERROR', statusCode: 503 });
  }
}

// Strategy 3: Queue for later processing
async function sendEmail(to, subject, body) {
  try {
    await emailService.send({ to, subject, body });
  } catch (err) {
    logger.error(`Email send failed, queuing: ${err.message}`);
    await emailQueue.add({ to, subject, body }); // Retry later via background worker
    // Still return success to user (email will be sent eventually)
  }
}

// Strategy 4: Feature flag off
async function renderDashboard(req, res) {
  let analyticsData = {};
  try {
    analyticsData = await heavyAnalyticsQuery(req.user.id);
  } catch (err) {
    if (featureFlags.get('dashboard_analytics_optional')) {
      logger.warn('Analytics failed, rendering without them');
      analyticsData = {}; // Dashboard still works, just no charts
    } else {
      throw err; // Analytics are critical, fail the request
    }
  }
  res.render('dashboard', { analyticsData });
}
Enter fullscreen mode Exit fullscreen mode

Unhandled Rejection & Exception Protection

// These handlers should be at the TOP of your entry file (index.js):
// They catch errors that escape your normal error handling

// Unhandled promise rejections (Node.js 15+ doesn't crash by default, but you should log!)
process.on('unhandledRejection', (reason, promise) => {
  logger.fatal('Unhandled rejection at:', promise, 'reason:', reason);
  // In development: crash to fix the bug
  if (process.env.NODE_ENV === 'development') {
    process.exit(1);
  }
  // In production: log and continue (but investigate!)
});

// Uncaught exceptions (WILL crash Node.js — this is cleanup before death)
process.on('uncaughtException', (err) => {
  logger.fatal('Uncaught exception:', err);

  // Try to shutdown gracefully:
  // 1. Notify monitoring system
  // 2. Close database connections
  // 3. Finish in-flight requests (if possible)

  // Then exit:
  process.exit(1); // Required: process must restart after uncaught exception
});

// Graceful shutdown on SIGTERM/SIGINT
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
process.on('SIGINT', () => gracefulShutdown('SIGINT'));

async function gracefulShutdown(signal) {
  logger.info(`${signal} received. Starting graceful shutdown...`);

  // Stop accepting new connections
  server.close(() => {
    logger.info('HTTP server closed.');
  });

  // Close DB connections
  await pool.end().catch(() => {});
  await redis.quit().catch(() => {});

  logger.info('Graceful shutdown complete.');
  process.exit(0);
}

// Timeout force-kill (if graceful shutdown takes too long):
setTimeout(() => {
  logger.error('Forced shutdown after timeout');
  process.exit(1);
}, 10000).unref(); // Don't keep process alive just for this timer
Enter fullscreen mode Exit fullscreen mode

What's your favorite error handling pattern? What's the worst bug caused by poor error handling that you've seen?

Follow @armorbreak for more practical developer guides.

Top comments (0)