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 error handling is wrong. Here's how to do it right.

The Problem

// ❌ The way most people handle errors
app.get('/users/:id', async (req, res) => {
  const user = await db.query('SELECT * FROM users WHERE id = ?', [req.params.id]);
  res.json(user); // What if query fails? 500 with no context.
});

// ❌ The slightly better way
app.get('/users/:id', async (req, res) => {
  try {
    const user = await db.query('SELECT * FROM users WHERE id = ?', [req.params.id]);
    res.json(user);
  } catch (err) {
    res.status(500).json({ error: 'Something went wrong' }); // Not helpful
  }
});
Enter fullscreen mode Exit fullscreen mode

Layer 1: Custom Error Classes

// Base error
class AppError extends Error {
  constructor(message, statusCode = 500, code = 'INTERNAL_ERROR') {
    super(message);
    this.statusCode = statusCode;
    this.code = code;
    this.isOperational = true; // Distinguish from programming errors
    Error.captureStackTrace(this, this.constructor);
  }
}

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

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

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

class ValidationError extends AppError {
  constructor(fields) {
    const message = 'Validation failed';
    super(message, 422, 'VALIDATION_ERROR');
    this.fields = fields; // { email: 'Invalid email', name: 'Required' }
  }
}

class ConflictError extends AppError {
  constructor(message = 'Resource already exists') {
    super(message, 409, 'CONFLICT');
  }
}

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

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

Layer 2: Async Error Handler

// Without this, unhandled promise rejections crash your app
const asyncHandler = (fn) => (req, res, next) =>
  Promise.resolve(fn(req, res, next)).catch(next);

// Usage — no try/catch needed in routes!
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);
}));

app.post('/users', asyncHandler(async (req, res) => {
  const { error, value } = validateUser(req.body);
  if (error) throw new ValidationError(error.details);

  try {
    const user = await User.create(value);
    res.status(201).json(user);
  } catch (err) {
    if (err.code === 'SQLITE_CONSTRAINT') {
      throw new ConflictError('Email already exists');
    }
    throw err; // Re-throw unexpected errors
  }
}));
Enter fullscreen mode Exit fullscreen mode

Layer 3: Central Error Handler

// This is the LAST middleware — catches ALL errors
app.use((err, req, res, next) => {
  // Default values
  err.statusCode = err.statusCode || 500;
  err.code = err.code || 'INTERNAL_ERROR';

  // Development: full error details
  if (process.env.NODE_ENV === 'development') {
    return res.status(err.statusCode).json({
      error: err.message,
      code: err.code,
      stack: err.stack,
      ...(err.fields && { fields: err.fields }),
    });
  }

  // Production: hide internal details
  if (err.isOperational) {
    return res.status(err.statusCode).json({
      error: err.message,
      code: err.code,
      ...(err.fields && { fields: err.fields }),
      ...(err.retryAfter && { retryAfter: err.retryAfter }),
    });
  }

  // Programming errors — log but don't expose
  console.error('UNEXPECTED ERROR:', err);
  return res.status(500).json({
    error: 'Internal server error',
    code: 'INTERNAL_ERROR',
  });
});
Enter fullscreen mode Exit fullscreen mode

Layer 4: Unhandled Rejection Handler

// Prevent process crash from unhandled promises
process.on('unhandledRejection', (reason, promise) => {
  console.error('Unhandled Rejection at:', promise, 'reason:', reason);
  // Log to your error tracking service
  Sentry.captureException(reason);
  // Don't crash — but log it
});

// Handle uncaught exceptions (last resort)
process.on('uncaughtException', (err) => {
  console.error('Uncaught Exception:', err);
  Sentry.captureException(err);
  // In production: graceful shutdown
  // In development: keep running for debugging
  if (process.env.NODE_ENV === 'production') {
    gracefulShutdown(err);
  }
});

// Graceful shutdown function
function gracefulShutdown(err) {
  console.error('Shutting down due to error:', err.message);

  // Close database connections
  if (db) db.close();

  // Close server
  server.close(() => {
    console.log('Server closed');
    process.exit(1);
  });

  // Force exit after 10 seconds
  setTimeout(() => {
    console.error('Forcing shutdown after timeout');
    process.exit(1);
  }, 10_000);
}
Enter fullscreen mode Exit fullscreen mode

Layer 5: Logging

// Structured error logging
const logError = (err, context = {}) => {
  const log = {
    timestamp: new Date().toISOString(),
    level: err.statusCode >= 500 ? 'error' : 'warn',
    message: err.message,
    code: err.code,
    statusCode: err.statusCode,
    path: context.path || '',
    method: context.method || '',
    userId: context.userId || null,
    isOperational: err.isOperational || false,
    ...(process.env.NODE_ENV === 'development' && { stack: err.stack }),
  };

  // Console
  console.error(JSON.stringify(log));

  // File
  fs.appendFileSync('logs/errors.log', JSON.stringify(log) + '\n');

  // External service (Sentry, DataDog, etc.)
  if (err.statusCode >= 500) {
    Sentry.captureException(err, { extra: context });
  }
};

// Use in error middleware
app.use((err, req, res, next) => {
  logError(err, {
    path: req.originalUrl,
    method: req.method,
    userId: req.user?.id,
  });
  // ... send response
});
Enter fullscreen mode Exit fullscreen mode

Common Mistakes

// ❌ 1. Swallowing errors silently
try {
  await riskyOperation();
} catch (err) {
  // empty catch block — error disappears!
}

// ✅ At minimum, log it
try {
  await riskyOperation();
} catch (err) {
  console.error('riskyOperation failed:', err.message);
  throw err; // Or throw a wrapped error
}

// ❌ 2. Using catch only for expected errors
fetch('/api/users')
  .then(r => r.json())
  .then(data => processUsers(data))
  .catch(err => console.log('Error!')); // Network error? Parse error? Who knows!

// ✅ Handle specific error types
fetch('/api/users')
  .then(r => {
    if (!r.ok) throw new AppError(`HTTP ${r.status}`, r.status);
    return r.json();
  })
  .then(data => processUsers(data))
  .catch(err => {
    if (err instanceof AppError) {
      showError(err.message);
    } else {
      showError('Network error — check your connection');
    }
  });

// ❌ 3. Not handling process-level errors
// Your app crashes on first unhandled rejection!

// ❌ 4. Exposing stack traces in production
res.status(500).json({ error: err.stack }); // Security risk!

// ❌ 5. Generic error messages everywhere
res.status(500).json({ error: 'Something went wrong' }); // Not actionable!
Enter fullscreen mode Exit fullscreen mode

Quick Checklist

□ Custom error classes for different error types
□ asyncHandler wrapping all async routes
□ Central error handler as last middleware
□ unhandledRejection listener
□ uncaughtException listener (with graceful shutdown)
□ Structured error logging
□ No stack traces in production responses
□ No swallowed errors (empty catch blocks)
□ Different messages for operational vs programming errors
Enter fullscreen mode Exit fullscreen mode

How do you handle errors in your Node.js apps? Any patterns I'm missing?

Follow @armorbreak for more Node.js content.

Top comments (0)