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)

Good error handling isn't about catching errors — it's about handling them gracefully so your app stays running.

The Philosophy

Bad error handling:
→ try/catch everything silently → bugs disappear, no one knows why
→ console.error(err) and move on → logs nobody reads
→ process.exit(1) on any error → crashes production
→ Return null/undefined → caller doesn't know WHY it failed

Good error handling:
→ Every error has a code and context → debugging is fast
→ Errors are categorized → different types get different treatment
→ Recovery is automatic where possible → self-healing apps
→ Users see helpful messages → not stack traces or "something went wrong"
Enter fullscreen mode Exit fullscreen mode

Custom Error Classes

// Base application error
class AppError extends Error {
  constructor(message, statusCode = 500, code = 'INTERNAL_ERROR') {
    super(message);
    this.name = 'AppError';
    this.statusCode = statusCode;
    this.code = code;
    this.timestamp = new Date().toISOString();
    // Capture stack trace (excluding constructor call)
    Error.captureStackTrace(this, this.constructor);
  }

  toJSON() {
    return {
      error: {
        code: this.code,
        message: this.message,
        statusCode: this.statusCode,
        timestamp: this.timestamp,
      }
    };
  }
}

// Domain-specific errors (inherit from AppError)
class NotFoundError extends AppError {
  constructor(resource, id) {
    super(`${resource} with ID '${id}' not found`, 404, 'NOT_FOUND');
    this.resource = resource;
    this.id = id;
  }
}

class ValidationError extends AppError {
  constructor(details) {
    const message = Array.isArray(details)
      ? `${details.length} validation error(s)`
      : 'Validation failed';
    super(message, 422, 'VALIDATION_ERROR');
    this.details = details; // Array of { field, issue }
  }
}

class ConflictError extends AppError {
  constructor(message) {
    super(message || 'Resource conflict', 409, 'CONFLICT');
  }
}

class RateLimitError extends AppError {
  constructor(retryAfterSeconds) {
    super('Too many requests', 429, 'RATE_LIMITED');
    this.retryAfter = retryAfterSeconds;
  }
}

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

class ForbiddenError extends AppError {
  constructor(message) {
    super(message || 'Access denied', 403, 'FORBIDDEN');
  }
}
Enter fullscreen mode Exit fullscreen mode

Centralized Error Handler (Express)

// middleware/errorHandler.js
function errorHandler(err, req, res, _next) {
  // Request ID for tracing
  const requestId = req.headers['x-request-id'] || 'unknown';

  // Log the error (structured logging!)
  const logData = {
    requestId,
    path: req.path,
    method: req.method,
    error: {
      name: err.name,
      message: err.message,
      code: err.code,
      stack: err.stack,
    },
    userId: req.user?.id,
    body: sanitizeBody(req.body),
  };

  if (err.statusCode >= 500) {
    // Server errors: full log for debugging
    console.error(JSON.stringify(logData));
  } else {
    // Client errors: info level (expected, not a bug)
    console.info(JSON.stringify(logData));
  }

  // Handle known error types
  if (err instanceof AppError) {
    const response = { ...err.toJSON(), requestId };

    if (err instanceof RateLimitError) {
      res.setHeader('Retry-After', err.retryAfter);
    }

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

  // Handle specific library errors
  if (err.code === 'SQLITE_CONSTRAINT') {
    return res.status(409).json({
      error: { code: 'DUPLICATE', message: 'Record already exists', requestId },
    });
  }

  if (err.code === 'ECONNREFUSED') {
    return res.status(503).json({
      error: { code: 'SERVICE_UNAVAILABLE', message: 'Service temporarily unavailable', requestId },
    });
  }

  // Unknown/unhandled errors: safe generic response
  console.error(`[UNHANDLED] ${requestId}: ${err.stack}`);

  const isProduction = process.env.NODE_ENV === 'production';
  return res.status(500).json({
    error: {
      code: 'INTERNAL_ERROR',
      message: isProduction ? 'An internal error occurred' : err.message,
      requestId,
      ...(isProduction ? {} : { stack: err.stack }),
    }
  });
}

// Async handler wrapper (eliminates try/catch boilerplate)
function asyncHandler(fn) {
  return (req, res, next) => {
    Promise.resolve(fn(req, res, next)).catch(next);
  };
}

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

app.use(errorHandler); // Must be last!
Enter fullscreen mode Exit fullscreen mode

Graceful Degradation Patterns

// Pattern 1: Fallback chain (try multiple sources)
async function getConfig() {
  const sources = [
    () => fetchFromRedis(),
    () => fetchFromDatabase(),
    () => fetchFromCacheFile(),
    () => ({ /* hardcoded defaults */ }),
  ];

  for (const source of sources) {
    try {
      const config = await source();
      if (config && Object.keys(config).length > 0) return config;
    } catch (err) {
      console.warn(`Config source failed: ${err.message}`);
      continue;
    }
  }
  throw new Error('All config sources failed');
}

// Pattern 2: Circuit breaker (stop hammering failing services)
class CircuitBreaker {
  constructor(fn, options = {}) {
    this.fn = fn;
    this.failureThreshold = options.failureThreshold ?? 5;
    this.resetTimeout = options.resetTimeout ?? 30000; // 30s
    this.state = 'CLOSED'; // CLOSED | OPEN | HALF_OPEN
    this.failures = 0;
    this.lastFailureTime = null;
  }

  async execute(...args) {
    if (this.state === 'OPEN') {
      if (Date.now() - this.lastFailureTime >= this.resetTimeout) {
        this.state = 'HALF_OPEN'; // Try one request
      } else {
        throw new Error('Circuit breaker OPEN - service unavailable');
      }
    }

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

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

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

const apiBreaker = new CircuitBreaker(() => externalAPI.call());
await apiBreaker.execute(); // Auto-fails fast when service is down

// Pattern 3: Retry with backoff
async function retryWithBackoff(fn, options = {}) {
  const {
    maxAttempts = 3,
    baseDelay = 1000,
    maxDelay = 10000,
    shouldRetry = (err) => !['4xx'].includes(err.statusCode),
  } = options;

  let lastError;

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

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

      const delay = Math.min(baseDelay * 2 ** (attempt - 1), maxDelay);
      const jitter = delay * (0.5 + Math.random() * 0.5); // Add jitter
      console.warn(`Attempt ${attempt}/${maxAttempts} failed, retrying in ${Math.round(jitter)}ms`);
      await sleep(jitter);
    }
  }

  throw lastError;
}

// Usage:
const data = await retryWithBackoff(
  () => fetchFromUnstableAPI(),
  { maxAttempts: 5, baseDelay: 1000, shouldRetry: (e) => e.code !== 'NOT_FOUND' }
);
Enter fullscreen mode Exit fullscreen mode

Unhandled Rejection & Uncaught Exception

// These MUST be set up at the top of your entry file
// They are your last line of defense

process.on('unhandledRejection', (reason, promise) => {
  console.error('[UNHANDLED REJECTION]', reason);
  // Don't crash! Log it and decide based on severity
  // In development: crash loudly
  if (process.env.NODE_ENV === 'development') {
    throw reason; // Crashes with full stack trace
  }
  // In production: log and continue (or gracefully shutdown)
});

process.on('uncaughtException', (error) => {
  console.error('[UNCAUGHT EXCEPTION]', error);
  // Best practice: shutdown gracefully
  // The process may be in an undefined state
  setTimeout(() => {
    process.exit(1); // Force exit if graceful shutdown takes too long
  }, 1000).unref(); // Don't keep process alive just for this

  // Cleanup before exit:
  // - Close database connections
  // - Flush logs
  // - Notify monitoring service
  server.close(() => {
    console.log('Server closed due to uncaught exception');
    process.exit(1);
  });
});

// Graceful shutdown handler
function setupGracefulShutdown(server) {
  const shutdown = (signal) => {
    console.log(`${signal} received, shutting down gracefully...`);

    // Stop accepting new connections
    server.close(() => {
      console.log('HTTP server closed');
      process.exit(0);
    });

    // Force exit after timeout
    setTimeout(() => {
      console.error('Forced shutdown after timeout');
      process.exit(1);
    }, 30000).unref();
  };

  process.on('SIGTERM', () => shutdown('SIGTERM'));
  process.on('SIGINT', () => shutdown('SIGINT'));
}
Enter fullscreen mode Exit fullscreen mode

Structured Logging

// logger.js — simple structured logger
const levels = { DEBUG: 0, INFO: 1, WARN: 2, ERROR: 3 };
const currentLevel = levels[process.env.LOG_LEVEL] || levels.INFO;

function log(level, message, meta = {}) {
  if (levels[level] < currentLevel) return;

  const entry = {
    timestamp: new Date().toISOString(),
    level,
    message,
    ...meta,
    pid: process.pid,
    hostname: require('os').hostname(),
  };

  const line = JSON.stringify(entry);

  switch (level) {
    case 'ERROR': case 'WARN': console.error(line); break;
    default: console.log(line);
  }
}

export const logger = {
  debug: (msg, meta) => log('DEBUG', msg, meta),
  info: (msg, meta) => log('INFO', msg, meta),
  warn: (msg, meta) => log('WARN', msg, meta),
  error: (msg, meta) => log('ERROR', msg, meta),
};

// Usage in error handlers:
logger.error('Database connection failed', {
  host: dbHost,
  port: dbPort,
  errorCode: err.code,
  retryCount: attempts,
});
// Output: {"timestamp":"...","level":"ERROR","message":"Database connection failed","host":"db.example.com","port":5432,"errorCode":"ECONNREFUSED","retryCount":3,...}
// This is parseable by log aggregation tools (Datadog, CloudWatch, etc.)
Enter fullscreen mode Exit fullscreen mode

Quick Checklist

Before shipping error handling:

□ Custom error classes with status codes and machine-readable codes?
□ Global error handler catches ALL unhandled errors?
□ Async errors wrapped (asyncHandler or equivalent)?
□ No raw stack traces exposed to users in production?
□ Every error logged with enough context to debug?
□ Request IDs included in all error responses?
□ Circuit breaker or rate limiting on external calls?
□ Retry logic for transient failures?
□ Graceful shutdown on SIGTERM/SIGINT?
□ UnhandledRejection listener registered?
□ UncaughtException listener registered?
□ Error responses follow consistent JSON format?
□ Client errors (4xx) vs server errors (5xx) handled differently?

Score yourself: Each ☑️ is a production-ready practice.
Enter fullscreen mode Exit fullscreen mode

What's the worst production error you've debugged? How did you find it?

Follow @armorbreak for more practical developer guides.

Top comments (0)