DEV Community

Alex Chen
Alex Chen

Posted on

Error Handling in Node.js: The Missing Guide

Error Handling in Node.js: The Missing Guide

Most Node.js tutorials skip error handling. Here's everything you need to know to stop your app from crashing silently.

The Golden Rule

Never let an error go unhandled. Unhandled errors in Node.js crash your process. In production, that means downtime.

Level 1: Synchronous Errors

// try/catch works for synchronous code
try {
  const data = JSON.parse(input); // Can throw SyntaxError
  const result = data.items.map(i => i.id); // Can throw TypeError
} catch (err) {
  console.error('Parse failed:', err.message);
  // Recover gracefully
}
Enter fullscreen mode Exit fullscreen mode

Simple. Boring. But what about async code?

Level 2: Promise Errors

// ❌ This error is SILENTLY lost
fetch('/api/data')
  .then(res => res.json())
  .then(data => process(data));
  // If process() throws, nobody catches it

// ✅ Always add .catch()
fetch('/api/data')
  .then(res => res.json())
  .then(data => process(data))
  .catch(err => {
    console.error('Fetch failed:', err);
    // Notify monitoring, show user error, etc.
  });
Enter fullscreen mode Exit fullscreen mode

The async/await Version

async function loadData() {
  try {
    const res = await fetch('/api/data');
    const data = await res.json();
    return process(data);
  } catch (err) {
    console.error('Load failed:', err);
    throw err; // Re-throw if caller needs to know
  }
}
Enter fullscreen mode Exit fullscreen mode

Key difference: await lets you use regular try/catch with async code. Prefer it over .then()/.catch() chains.

Level 3: Express.js Error Handling

const express = require('express');
const app = express();

// Regular middleware: errors are caught by Express
app.get('/users', async (req, res, next) => {
  try {
    const users = await db.query('SELECT * FROM users');
    res.json(users);
  } catch (err) {
    next(err); // Pass to error handler
  }
});

// Async wrapper (eliminates try/catch boilerplate)
const asyncHandler = (fn) => (req, res, next) =>
  Promise.resolve(fn(req, res, next)).catch(next);

app.get('/posts', asyncHandler(async (req, res) => {
  const posts = await db.query('SELECT * FROM posts');
  res.json(posts);
  // If this throws, asyncHandler catches and calls next(err)
}));

// Global error handler — MUST have 4 parameters
// MUST be registered AFTER all routes
app.use((err, req, res, next) => {
  console.error(`[${new Date().toISOString()}] ${err.stack}`);

  const status = err.statusCode || 500;
  const message = err.expose ? err.message : 'Internal server error';

  res.status(status).json({
    error: {
      status,
      message,
      ...(process.env.NODE_ENV === 'development' && { stack: err.stack }),
    }
  });
});
Enter fullscreen mode Exit fullscreen mode

Level 4: Process-Level Safety Nets

// These catch errors that escape everything else
// They're your LAST line of defense

// 1. Unhandled promise rejections
process.on('unhandledRejection', (reason, promise) => {
  console.error('Unhandled Rejection at:', promise, 'reason:', reason);
  // Log to monitoring service
  // Don't crash in development, do crash in production
  if (process.env.NODE_ENV === 'production') {
    process.exit(1);
  }
});

// 2. Uncaught exceptions
process.on('uncaughtException', (err) => {
  console.error('Uncaught Exception:', err);
  // Log the error
  // Then crash — state is corrupted, don't continue
  // (Your process manager will restart)
  process.exit(1);
});

// 3. Graceful shutdown
const server = app.listen(3000);

function shutdown(signal) {
  console.log(`${signal} received. Shutting down gracefully...`);

  server.close(() => {
    console.log('HTTP server closed');
    db.close().then(() => {
      console.log('Database connections closed');
      process.exit(0);
    });
  });

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

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

Level 5: Custom Error Classes

// Base application error
class AppError extends Error {
  constructor(message, statusCode = 500, isOperational = true) {
    super(message);
    this.statusCode = statusCode;
    this.isOperational = isOperational;
    Error.captureStackTrace(this, this.constructor);
  }
}

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

class ValidationError extends AppError {
  constructor(details) {
    super('Validation failed', 400);
    this.details = details;
  }
}

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

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

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

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

Level 6: Retry Pattern

async function withRetry(fn, options = {}) {
  const {
    maxRetries = 3,
    delay = 1000,
    backoff = 2,
    retryOn = (err) => true, // Which errors to retry
  } = options;

  let lastError;

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

      if (attempt === maxRetries || !retryOn(err)) {
        throw err;
      }

      const waitTime = delay * Math.pow(backoff, attempt);
      console.warn(`Attempt ${attempt + 1} failed: ${err.message}. Retrying in ${waitTime}ms...`);
      await new Promise(resolve => setTimeout(resolve, waitTime));
    }
  }

  throw lastError;
}

// Usage:
const data = await withRetry(
  () => fetch('https://api.example.com/data').then(r => r.json()),
  {
    maxRetries: 5,
    retryOn: (err) => err.name === 'TypeError', // Only retry network errors
  }
);
Enter fullscreen mode Exit fullscreen mode

Level 7: Circuit Breaker

class CircuitBreaker {
  constructor(fn, options = {}) {
    this.fn = fn;
    this.failureThreshold = options.failureThreshold || 5;
    this.resetTimeout = options.resetTimeout || 30000;
    this.state = 'CLOSED'; // CLOSED = normal, OPEN = failing, HALF = testing
    this.failures = 0;
    this.nextRetry = 0;
  }

  async call(...args) {
    if (this.state === 'OPEN') {
      if (Date.now() < this.nextRetry) {
        throw new Error('Circuit breaker is OPEN');
      }
      this.state = 'HALF'; // Try one request
    }

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

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

  onFailure() {
    this.failures++;
    if (this.failures >= this.failureThreshold) {
      this.state = 'OPEN';
      this.nextRetry = Date.now() + this.resetTimeout;
      console.error(`Circuit breaker OPEN for ${this.resetTimeout}ms`);
    }
  }
}

// Usage:
const breaker = new CircuitBreaker(
  () => fetch('https://flaky-api.example.com/data').then(r => r.json()),
  { failureThreshold: 3, resetTimeout: 60000 }
);

try {
  const data = await breaker.call();
} catch (err) {
  // Return cached data or show "service unavailable"
}
Enter fullscreen mode Exit fullscreen mode

The Quick Reference

Pattern When Complexity
try/catch Sync code Basic
.catch() / await + try Async code Basic
Express error handler HTTP APIs Medium
Process event handlers Last resort safety net Medium
Custom error classes Typed error handling Low
Retry Transient failures Medium
Circuit breaker Unstable dependencies Medium

What Most Tutorials Don't Tell You

  1. process.on('uncaughtException') should crash your app. Don't try to continue — your state is corrupted. Let your process manager restart.

  2. Error objects should have statusCode and isOperational. This lets your error handler decide whether to alert on-call or just log.

  3. Never swallow errors. catch (err) {} without logging is the worst thing you can do. At minimum: console.error(err).

  4. Test your error paths. Happy path tests pass easily. Error path tests find real bugs.

  5. Errors should be actionable. "Something went wrong" helps nobody. "User 'abc' not found in database 'users'" helps everyone.


Follow @armorbreak for more production-ready Node.js patterns.

Top comments (0)