TechLead
Lesson 8 of 16
5 min read
Node.js

Error Handling in Node.js

Handle errors properly: custom error classes, async error patterns, graceful shutdown, and operational vs programmer errors

Why Error Handling Matters

Poor error handling is the number one cause of Node.js application crashes in production. Understanding the difference between operational errors (expected failures) and programmer errors (bugs), and handling each appropriately, is critical for building reliable applications.

⚠️ Two Types of Errors

Operational Errors

Expected failures: network timeout, file not found, invalid user input, database connection lost

Programmer Errors

Bugs: TypeError, reading undefined property, off-by-one, passing wrong argument types

Custom Error Classes

Create structured errors with status codes, error codes, and context:

// errors.js
class AppError extends Error {
  constructor(message, statusCode, errorCode) {
    super(message);
    this.name = this.constructor.name;
    this.statusCode = statusCode || 500;
    this.errorCode = errorCode || 'INTERNAL_ERROR';
    this.isOperational = true;
    Error.captureStackTrace(this, this.constructor);
  }
}

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

class ValidationError extends AppError {
  constructor(message, fields = []) {
    super(message, 400, 'VALIDATION_ERROR');
    this.fields = fields;
  }
}

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

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

// Usage
function getUser(id) {
  const user = db.findById(id);
  if (!user) throw new NotFoundError('User');
  return user;
}

function validateEmail(email) {
  if (!email || !email.includes('@')) {
    throw new ValidationError('Invalid email', ['email']);
  }
}

Async/Await Error Handling

// Pattern 1: try/catch blocks
async function fetchUserData(userId) {
  try {
    const user = await db.users.findById(userId);
    if (!user) throw new NotFoundError('User');

    const orders = await db.orders.findByUserId(userId);
    return { user, orders };
  } catch (err) {
    if (err instanceof NotFoundError) {
      throw err; // Re-throw operational errors
    }
    // Wrap unexpected errors
    throw new AppError(`Failed to fetch user data: ${err.message}`, 500);
  }
}

// Pattern 2: Wrapper to avoid repetitive try/catch
function asyncHandler(fn) {
  return (req, res, next) => {
    Promise.resolve(fn(req, res, next)).catch(next);
  };
}

// Pattern 3: Result tuple (Go-style)
async function safeAsync(promise) {
  try {
    const result = await promise;
    return [null, result];
  } catch (err) {
    return [err, null];
  }
}

// Usage of Go-style pattern
const [err, user] = await safeAsync(db.users.findById(id));
if (err) {
  console.error('Failed to find user:', err);
  return;
}
console.log('Found user:', user.name);

Global Error Handlers

// Catch unhandled promise rejections
process.on('unhandledRejection', (reason, promise) => {
  console.error('Unhandled Rejection:', reason);
  // Log to error tracking service (Sentry, Datadog, etc.)
  // In production, consider graceful shutdown
});

// Catch uncaught exceptions
process.on('uncaughtException', (err) => {
  console.error('Uncaught Exception:', err);
  // IMPORTANT: After uncaughtException, the process is in
  // an undefined state. You MUST exit.
  // Log the error, then shut down gracefully.
  gracefulShutdown(1);
});

// Centralized error handler for Express-style apps
function errorHandler(err, req, res, next) {
  // Log error details
  console.error({
    message: err.message,
    code: err.errorCode,
    stack: process.env.NODE_ENV === 'development' ? err.stack : undefined,
    url: req.url,
    method: req.method,
  });

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

  // Programmer errors: hide details from client
  res.status(500).json({
    error: 'INTERNAL_ERROR',
    message: 'Something went wrong'
  });
}

Graceful Shutdown

const http = require('http');

const server = http.createServer(app);
const connections = new Set();

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

async function gracefulShutdown(exitCode = 0) {
  console.log('Shutting down gracefully...');

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

  // 2. Close existing connections
  for (const conn of connections) {
    conn.end();
  }

  // 3. Close database connections
  try {
    await db.close();
    console.log('Database connections closed');
  } catch (err) {
    console.error('Error closing database:', err);
  }

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

  process.exit(exitCode);
}

// Listen for termination signals
process.on('SIGTERM', () => gracefulShutdown(0));
process.on('SIGINT', () => gracefulShutdown(0));

Error Handling in Event Emitters

const EventEmitter = require('events');

class TaskRunner extends EventEmitter {
  async runTask(name, fn) {
    try {
      this.emit('start', name);
      const result = await fn();
      this.emit('complete', name, result);
      return result;
    } catch (err) {
      // Always emit error events — unhandled 'error'
      // events on EventEmitters crash the process!
      this.emit('error', err, name);
      throw err;
    }
  }
}

const runner = new TaskRunner();

// IMPORTANT: always attach an error listener
runner.on('error', (err, taskName) => {
  console.error(`Task "${taskName}" failed:`, err.message);
});

runner.on('complete', (name, result) => {
  console.log(`Task "${name}" completed:`, result);
});

await runner.runTask('fetchData', async () => {
  // ... task logic
});

💡 Key Takeaways

  • • Distinguish operational errors from programmer errors
  • • Create custom error classes with status codes and error codes
  • • Always handle unhandledRejection and uncaughtException
  • • After uncaughtException, the process must exit
  • • Implement graceful shutdown to clean up resources on exit

Continue Learning