Node.js Error Handling

Robust Node.js applications handle errors deliberately at every layer, from callback conventions to synchronous try/catch to last-resort process-level safety nets.

The Error-First Callback Convention

Before promises existed, Node.js established a convention for asynchronous callbacks: the first argument is always reserved for an error (or null if none occurred), and subsequent arguments carry the successful result. This convention is called 'error-first callbacks' and still appears throughout core APIs and older libraries.

Handling an error-first callback

const fs = require('fs');

fs.readFile('data.txt', 'utf8', (err, data) => {
  if (err) {
    console.error('Read failed:', err.message);
    return; // stop here, don't touch `data`
  }
  console.log('File contents:', data);
});
Note: Always check `err` before using the result argument. Forgetting the early return after handling an error is a classic bug — execution falls through and tries to use undefined data.

try/catch for Synchronous and Async Code

try/catch handles exceptions thrown by synchronous code directly, and — when combined with async/await — it also handles rejected promises, since await re-throws a promise's rejection reason as a regular exception inside the function.

try/catch with JSON.parse and async/await

const fs = require('fs/promises');

async function parseConfig(path) {
  try {
    const raw = await fs.readFile(path, 'utf8');
    return JSON.parse(raw); // throws SyntaxError on bad JSON
  } catch (err) {
    if (err.code === 'ENOENT') {
      console.error('Config file not found:', path);
    } else if (err instanceof SyntaxError) {
      console.error('Config file has invalid JSON');
    } else {
      console.error('Unexpected error:', err.message);
    }
    return null;
  }
}

Notice how the catch block branches on err.code and the error's constructor. Node's built-in errors carry a `code` string like ENOENT (file not found) or EACCES (permission denied), which is far more reliable to check than parsing the error message text.

Custom Error Classes

For application-specific failures, extend the built-in Error class. This lets calling code distinguish your errors from generic ones using instanceof, while still getting a proper stack trace.

Defining and throwing a custom error

class ValidationError extends Error {
  constructor(message, field) {
    super(message);
    this.name = 'ValidationError';
    this.field = field;
  }
}

function validateAge(age) {
  if (typeof age !== 'number' || age < 0) {
    throw new ValidationError('Age must be a non-negative number', 'age');
  }
  return age;
}

try {
  validateAge(-5);
} catch (err) {
  if (err instanceof ValidationError) {
    console.error(`Invalid field '${err.field}': ${err.message}`);
  } else {
    throw err;
  }
}
MechanismCatchesTypical use
Error-first callbackErrors passed explicitly as first argumentLegacy async APIs, node core callbacks
try/catchThrown exceptions and awaited rejectionsSynchronous logic and async/await code
process.on('uncaughtException')Any error that escapes all other handlingLast-resort logging before shutdown

process-Level Safety Nets

Sometimes an error slips past every try/catch — a bug in a callback, a rejected promise nobody awaited. Node lets you listen for these at the process level, but they should be treated as a last resort for logging and graceful shutdown, not a substitute for proper error handling.

Listening for uncaught exceptions and unhandled rejections

process.on('uncaughtException', (err) => {
  console.error('Uncaught exception, shutting down:', err);
  process.exit(1);
});

process.on('unhandledRejection', (reason) => {
  console.error('Unhandled promise rejection:', reason);
  process.exit(1);
});
Note: Once uncaughtException fires, Node's internal state may be inconsistent. The recommended practice is to log the error and exit the process cleanly, letting a process manager (like PM2 or a container orchestrator) restart it, rather than trying to keep running.

Exercise: Node.js Error Handling

Why doesn't a `try/catch` block catch an error thrown inside a `setTimeout` callback?