🟢 Node.js Q24 / 118

How to handle errors in Node.js?

AI-Powered Answer ✓ Answered

Robust error handling is crucial for building stable and resilient Node.js applications. Node.js offers several mechanisms to deal with different types of errors, including synchronous, asynchronous, and system errors. Understanding these methods is key to creating reliable services.

Synchronous Errors: try...catch

For synchronous code, the standard JavaScript try...catch block is used to catch exceptions that occur within the try block. This mechanism prevents the application from crashing due to unexpected synchronous errors.

javascript
function divide(a, b) {
  if (b === 0) {
    throw new Error('Division by zero is not allowed.');
  }
  return a / b;
}

try {
  const result = divide(10, 2);
  console.log('Result:', result);

  const errorResult = divide(10, 0);
  console.log('Error Result (should not reach here):', errorResult);
} catch (error) {
  console.error('Synchronous error caught:', error.message);
}

Asynchronous Errors: Callbacks (Error-First Pattern)

In older Node.js code and with many core modules, the error-first callback pattern is common. The first argument to a callback function is reserved for an Error object, or null if no error occurred. It's crucial to check for this error argument first.

javascript
const fs = require('fs');

fs.readFile('nonexistent.txt', 'utf8', (err, data) => {
  if (err) {
    console.error('File read error:', err.message);
    // Handle the error, perhaps by notifying the user or logging it
    return;
  }
  console.log('File content:', data.substring(0, 50) + '...');
});

fs.readFile('package.json', 'utf8', (err, data) => {
  if (err) {
    console.error('File read error:', err.message);
    return;
  }
  console.log('package.json content loaded successfully.');
});

Asynchronous Errors: Promises

Promises provide a more structured way to handle asynchronous operations. Errors in a promise chain are caught using the .catch() method, or the second argument to .then(). The .catch() method is generally preferred for readability.

javascript
function fetchData() {
  return new Promise((resolve, reject) => {
    // Simulate an async operation that might fail
    setTimeout(() => {
      const success = Math.random() > 0.5; // Simulate success or failure randomly
      if (success) {
        resolve('Data fetched successfully!');
      } else {
        reject(new Error('Failed to fetch data. Network issues?'));
      }
    }, 200);
  });
}

fetchData()
  .then(data => console.log('Promise success:', data))
  .catch(error => console.error('Promise error caught:', error.message));

// Chaining promises and error handling
Promise.resolve(1)
  .then(result => {
    console.log('Step 1:', result);
    if (result === 1) {
      throw new Error('Intentional error in chain');
    }
    return result + 1;
  })
  .then(result => console.log('Step 2 (should not reach here):', result))
  .catch(err => console.error('Promise chain error:', err.message));

Asynchronous Errors: Async/Await

async/await syntax allows writing asynchronous code that looks and behaves more like synchronous code. This makes error handling with try...catch blocks intuitive for promises, as rejections are effectively 'thrown' and caught.

javascript
async function fetchDataAsync() {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      const success = false; // Force failure for demonstration
      if (success) {
        resolve('Data fetched successfully with async/await!');
      } else {
        reject(new Error('Failed to fetch data via async/await.'));
      }
    }, 200);
  });
}

async function processData() {
  try {
    console.log('Attempting to fetch data with async/await...');
    const data = await fetchDataAsync();
    console.log('Async/Await success:', data);
  } catch (error) {
    console.error('Async/Await error caught:', error.message);
  }
}

processData();

Event Emitters

Objects that inherit from EventEmitter (like streams, HTTP servers, etc.) emit an error event when an internal error occurs. If no listener is registered for the error event, Node.js will throw an uncaught exception, which typically crashes the process. It's vital to always add an error listener for EventEmitter instances.

javascript
const EventEmitter = require('events');
const myEmitter = new EventEmitter();

// Register an error listener to prevent process crash
myEmitter.on('error', (err) => {
  console.error('Emitter error caught:', err.message);
  // Depending on the severity, you might want to log more details
  // or perform specific cleanup.
});

// Simulate an error being emitted
myEmitter.emit('error', new Error('Something went wrong within the emitter!'));

// Example of an event emitter that doesn't emit errors frequently
class MyResource extends EventEmitter {
  constructor() {
    super();
    setInterval(() => {
      if (Math.random() > 0.8) {
        this.emit('error', new Error('Resource failure!'));
      }
      this.emit('data', 'some data');
    }, 500);
  }
}

const resource = new MyResource();
resource.on('data', (data) => console.log('Resource data:', data));
resource.on('error', (err) => console.error('MyResource error:', err.message));

Global Uncaught Exceptions and Unhandled Rejections

Node.js provides global handlers for errors that escape all other mechanisms. process.on('uncaughtException') catches synchronous errors not caught by try...catch. process.on('unhandledRejection') catches Promise rejections not handled by a .catch() block. These should generally be used for logging, cleanup, and graceful shutdown (i.e., exiting the process), *not* for resuming application state, as the application might be in an unpredictable state after such an error.

javascript
// Always add these handlers at the very top of your application entry point
process.on('uncaughtException', (err) => {
  console.error('Uncaught Exception caught:', err.message, err.stack);
  // Log the error, perform urgent cleanup, and then terminate the process.
  // It's unsafe to continue normal operation after an uncaught exception.
  process.exit(1); 
});

process.on('unhandledRejection', (reason, promise) => {
  console.error('Unhandled Rejection at:', promise, 'reason:', reason.message, reason.stack);
  // Log the error. For unhandled rejections, you might decide to terminate
  // or just log, depending on strictness and whether the rejection indicates
  // a critical unrecoverable state. Often, it's safer to exit.
  process.exit(1);
});

// --- Examples (uncomment to test, but be aware they will terminate the process) ---

// Example of uncaught synchronous exception
// setTimeout(() => {
//   console.log('Throwing uncaught synchronous error...');
//   throw new Error('This is an uncaught synchronous error!');
// }, 500);

// Example of unhandled promise rejection
// setTimeout(() => {
//   console.log('Rejecting a promise without a .catch() handler...');
//   Promise.reject(new Error('This is an unhandled promise rejection!'));
// }, 1000);

Custom Error Classes

Creating custom error classes that extend the built-in Error class can significantly improve error identification, categorisation, and handling logic, especially for domain-specific errors in larger applications. This allows you to attach additional properties (like HTTP status codes or custom error codes) and use instanceof for type-checking.

javascript
class ValidationError extends Error {
  constructor(message, fields = []) {
    super(message);
    this.name = 'ValidationError';
    this.statusCode = 400;
    this.fields = fields;
  }
}

class NotFoundError extends Error {
  constructor(message) {
    super(message);
    this.name = 'NotFoundError';
    this.statusCode = 404;
  }
}

function validateInput(data) {
  if (!data || !data.username) {
    throw new ValidationError('Username is required.', ['username']);
  }
  if (data.password.length < 6) {
    throw new ValidationError('Password must be at least 6 characters.', ['password']);
  }
  return true;
}

try {
  validateInput({ username: 'user123', password: 'abc' });
} catch (error) {
  if (error instanceof ValidationError) {
    console.error(`Validation Error (${error.statusCode}): ${error.message} Fields: ${error.fields.join(', ')}`);
  } else {
    console.error('An unexpected error occurred:', error.message);
  }
}

try {
  throw new NotFoundError('User with ID 123 not found.');
} catch (error) {
  if (error instanceof NotFoundError) {
    console.error(`Not Found Error (${error.statusCode}): ${error.message}`);
  } else {
    console.error('An unexpected error occurred:', error.message);
  }
}

Best Practices

  • Always Handle Errors: Never let an error go unhandled, as it can crash your application or lead to unpredictable behavior.
  • Log Errors Effectively: Include stack traces, timestamps, and relevant context in your logs. Use dedicated logging libraries (e.g., Winston, Pino).
  • Differentiate Sync vs. Async: Use try...catch for synchronous code and Promise.catch(), async/await try...catch, or error-first callbacks for asynchronous operations.
  • Avoid Global Handlers for Recovery: process.on('uncaughtException') and process.on('unhandledRejection') should be used for graceful shutdown and critical error logging, not for attempting to resume normal application flow.
  • Throw Error Objects: Always throw instances of Error or custom error classes, not strings or other primitives, to preserve stack traces and other useful information.
  • Propagate Errors: If a component cannot fully handle an error, re-throw or reject it to allow higher-level components to decide on the appropriate action.
  • Use Custom Errors: Define custom error classes for domain-specific errors to improve clarity, type-checking, and targeted error handling.
  • Centralized Error Handling: In web applications (e.g., Express), use middleware for a single point of error handling to catch and format errors consistently.