Error Handling Patterns

Difficulty: Intermediate

Question

What are the best practices for error handling in a Node.js application? Explain operational vs programmer errors.

Answer

Error handling in Node.js requires distinguishing between operational errors (expected failures like invalid input, network timeouts, file not found) and programmer errors (bugs like undefined references, type errors, logic mistakes).

Operational errors should be caught, logged, and responded to with appropriate HTTP status codes and user-friendly messages. Programmer errors indicate bugs and should crash the process (in production, use PM2 or similar to auto-restart).

The recommended pattern uses a custom AppError class with status codes and an isOperational flag. All routes use async/await with errors forwarded to a centralized error handler via next(err). The global error handler logs the error and sends a sanitized response.

For unhandled exceptions and rejections, attach handlers to process.on('uncaughtException') and process.on('unhandledRejection'). In production, log the error, clean up resources, and exit the process. Let your process manager restart it.

Code examples

Custom Error Class Hierarchy

// errors/AppError.js
class AppError extends Error {
  constructor(message, statusCode) {
    super(message);
    this.statusCode = statusCode;
    this.isOperational = true;
    Error.captureStackTrace(this, this.constructor);
  }
}

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 UnauthorizedError extends AppError {
  constructor(message = 'Unauthorized') {
    super(message, 401);
  }
}

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

module.exports = { AppError, NotFoundError, ValidationError, UnauthorizedError, ForbiddenError };

Custom error classes provide semantic meaning. isOperational distinguishes expected failures from bugs. captureStackTrace gives clean stack traces.

Centralized Error Handler

// middleware/errorHandler.js
function errorHandler(err, req, res, next) {
  // Default values
  err.statusCode = err.statusCode || 500;

  // Log error
  if (err.statusCode >= 500) {
    console.error('SERVER ERROR:', {
      message: err.message,
      stack: err.stack,
      path: req.path,
      method: req.method,
    });
  }

  // Operational error: send details to client
  if (err.isOperational) {
    return res.status(err.statusCode).json({
      error: err.message,
      ...(err.details && { details: err.details }),
    });
  }

  // Programmer error: hide details in production
  if (process.env.NODE_ENV === 'production') {
    return res.status(500).json({ error: 'Internal server error' });
  }

  // Development: show full error
  res.status(500).json({
    error: err.message,
    stack: err.stack,
  });
}

// Register AFTER all routes
app.use(errorHandler);

The centralized handler treats operational and programmer errors differently. In production, internal errors return generic messages to avoid leaking details.

Process-Level Error Handling

// Handle uncaught exceptions (sync bugs)
process.on('uncaughtException', (err) => {
  console.error('UNCAUGHT EXCEPTION:', err);
  // Perform cleanup, then exit
  process.exit(1);
});

// Handle unhandled promise rejections
process.on('unhandledRejection', (reason, promise) => {
  console.error('UNHANDLED REJECTION:', reason);
  // In Node 15+, this crashes by default
  process.exit(1);
});

// In services - always throw operational errors
async function getUserById(id) {
  const user = await prisma.user.findUnique({ where: { id } });
  if (!user) {
    throw new NotFoundError('User'); // Operational
  }
  return user;
}

// In controllers - errors auto-forward to handler (Express 5)
app.get('/api/users/:id', async (req, res) => {
  const user = await getUserById(Number(req.params.id));
  res.json(user);
  // If getUserById throws, Express 5 catches it
  // and forwards to errorHandler
});

Process handlers catch errors that slip through all other handlers. In production, exit and let PM2 restart. Express 5 auto-catches async throws.

Key points

Concepts covered

Error Classes, Global Handler, Async Errors, Operational vs Programmer Errors