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 what they don't tell you.

The Three Types of Errors

// 1. Operational Errors (expected, handle them)
// - File not found, network timeout, invalid input
// - These ARE going to happen. Plan for them.

// 2. Programmer Errors (bugs, fix them)
// - TypeError, ReferenceError, logic errors  
// - These should NOT happen. Fix the code.

// 3. System Errors (infrastructure, retry them)
// - Out of memory, connection refused, DNS failure
// - Often transient. Retry with backoff.
Enter fullscreen mode Exit fullscreen mode

Synchronous Error Handling

// Use try/catch for synchronous code
function parseConfig(filePath) {
  try {
    const raw = fs.readFileSync(filePath, 'utf8');
    return JSON.parse(raw);
  } catch (err) {
    if (err.code === 'ENOENT') {
      console.error(`Config file not found: ${filePath}`);
      return getDefaultConfig();
    }
    if (err instanceof SyntaxError) {
      console.error(`Invalid JSON in ${filePath}: ${err.message}`);
      throw new ConfigError('Malformed config file', { filePath });
    }
    throw err; // Unknown error, let it bubble
  }
}
Enter fullscreen mode Exit fullscreen mode

Async Error Handling (The Right Way)

❌ Wrong: Callbacks

// Unhandled rejection if readFile fails
fs.readFile('data.json', (err, data) => {
  const parsed = JSON.parse(data); // What if data is malformed?
});
Enter fullscreen mode Exit fullscreen mode

✅ Right: Promises with try/catch

async function loadData() {
  try {
    const raw = await fs.promises.readFile('data.json', 'utf8');
    return JSON.parse(raw);
  } catch (err) {
    if (err.code === 'ENOENT') {
      return getDefaultData();
    }
    throw new DataError('Failed to load data', { cause: err });
  }
}
Enter fullscreen mode Exit fullscreen mode

✅ Right: Express middleware

// Async errors need special handling in Express!
app.get('/api/users/:id', async (req, res, next) => {
  try {
    const user = await User.findById(req.params.id);
    if (!user) return res.status(404).json({ error: 'User not found' });
    res.json({ data: user });
  } catch (err) {
    next(err); // Pass to error handler!
  }
});

// Global error handler (MUST have 4 parameters)
app.use((err, req, res, _next) => {
  console.error(`[ERR] ${req.method} ${req.path}:`, err);

  // Don't leak stack traces in production
  const message = process.env.NODE_ENV === 'production' 
    ? 'Internal server error' 
    : err.message;

  res.status(err.statusCode || 500).json({
    error: { 
      code: err.code || 'INTERNAL_ERROR',
      message,
      ...(process.env.NODE_ENV !== 'production' && { stack: err.stack })
    }
  });
});
Enter fullscreen mode Exit fullscreen mode

Custom Error Classes

// Base application error
class AppError extends Error {
  constructor(message, { statusCode = 500, code = 'APP_ERROR', details = null } = {}) {
    super(message);
    this.name = this.constructor.name;
    this.statusCode = statusCode;
    this.code = code;
    this.details = details;
    Error.captureStackTrace(this, this.constructor);
  }
}

// Specific error types
class NotFoundError extends AppError {
  constructor(resource, id) {
    super(`${resource} not found`, { 
      statusCode: 404, 
      code: 'NOT_FOUND',
      details: { resource, id } 
    });
  }
}

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

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

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

// Usage
if (!user) throw new NotFoundError('User', id);
if (!isValidEmail(email)) throw new ValidationError([{ field: 'email', issue: 'Invalid format' }]);
Enter fullscreen mode Exit fullscreen mode

Retry with Exponential Backoff

async function withRetry(fn, { maxAttempts = 3, baseDelay = 1000, jitter = true } = {}) {
  let lastError;

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

      // Don't retry client errors (4xx)
      if (err.statusCode >= 400 && err.statusCode < 500) throw err;

      if (attempt === maxAttempts) throw err;

      const delay = baseDelay * Math.pow(2, attempt - 1);
      const jitterAmount = jitter ? Math.random() * 1000 : 0;

      console.warn(`Attempt ${attempt}/${maxAttempts} failed: ${err.message}. Retrying in ${Math.round(delay + jitterAmount)}ms`);

      await new Promise(resolve => setTimeout(resolve, delay + jitterAmount));
    }
  }

  throw lastError;
}

// Usage
const data = await withRetry(
  () => fetch('https://api.example.com/data').then(r => r.json()),
  { maxAttempts: 5, baseDelay: 500 }
);
Enter fullscreen mode Exit fullscreen mode

Graceful Shutdown

const server = app.listen(3000);

// Track active connections
const connections = new Set();
server.on('connection', (conn) => {
  connections.add(conn);
  conn.on('close', () => connections.delete(conn));
});

// Handle shutdown signals
function shutdown(signal) {
  console.log(`\n${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);
  }, 10000);
}

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

// Handle uncaught exceptions (last resort!)
process.on('uncaughtException', (err) => {
  console.error('UNCAUGHT EXCEPTION:', err);
  // Log and restart — don't try to continue
  shutdown('UNCAUGHT_EXCEPTION');
});

process.on('unhandledRejection', (reason, promise) => {
  console.error('UNHANDLED REJECTION:', reason);
  // Log it, but don't crash for unhandled rejections (Node 15+ changed this)
});
Enter fullscreen mode Exit fullscreen mode

The Error Handling Checklist

  • [ ] Every await in a try/catch or .catch()
  • [ ] Express async routes pass errors to next(err)
  • [ ] Global error handler doesn't leak stack traces in production
  • [ ] Custom error classes for different error types
  • [ ] Network calls use retry with backoff
  • [ ] Database queries handle connection errors
  • [ ] Input validation before processing
  • [ ] Graceful shutdown on SIGTERM/SIGINT
  • [ ] uncaughtException and unhandledRejection handlers
  • [ ] Error logging with context (request ID, user, path)
  • [ ] Client-friendly error messages (not raw stack traces)

What's your approach to error handling? Any patterns I missed?

Follow @armorbreak for more Node.js guides.

Top comments (0)