Error Handling Patterns

intermediate nodejs errors async production

Error handling in Node is a minefield because there are three different error-delivery mechanisms: thrown exceptions, callback’s err first argument, and rejected Promises. Mix them up and errors silently disappear.

The async/await rule

With async/await, errors propagate via thrown exceptions — same as sync code. try/catch catches them.

async function getUser(id) {
  try {
    const user = await db.findUser(id);
    return user;
  } catch (err) {
    logger.error({ err, id }, 'failed to load user');
    throw err; // re-throw, let caller decide
  }
}

Key word: re-throw. Catching to log and then returning undefined (silently swallowing) is how bugs hide for months. Either re-throw, or return a sentinel and document it loudly.

Promises without await

If we fire a promise and don’t await it (or .catch it), a rejection becomes an unhandled promise rejection. Bad.

// BAD
async function handler(req, res) {
  doBackgroundWork(); // returns a promise, we ignored it
  res.json({ ok: true });
}

If doBackgroundWork throws, the error vanishes. Either await it, or chain a .catch:

doBackgroundWork().catch((err) => logger.error({ err }, 'bg work failed'));

The two process-level safety nets

Node fires these events for errors we missed:

process.on('uncaughtException', (err, origin) => {
  logger.fatal({ err, origin }, 'uncaught exception');
  // do minimal sync cleanup, then EXIT
  process.exit(1);
});

process.on('unhandledRejection', (reason, promise) => {
  logger.error({ reason }, 'unhandled rejection');
  // In modern Node, these are fatal by default. Crash.
  throw reason;
});

In simple language: uncaughtException is “a thrown error nobody caught.” unhandledRejection is “a rejected promise nobody .catched.” Both mean we have a bug somewhere.

To crash or not to crash?

This is the interview question. The answer: on uncaughtException, always crash.

Why? After an uncaught exception, our process is in an undefined state. Half-completed transactions. Half-closed file descriptors. Variables in inconsistent state. Continuing to serve traffic could corrupt data.

The correct flow:

Error Decision Tree
Operational error (DB timeout, 404, invalid input, network blip)
→ catch, log, return error response. Keep running.
Programmer error (TypeError, ReferenceError, "cannot read property of undefined")
→ crash. Process manager restarts us. Fix the bug.
Out of memory
→ already crashing. Make sure restart is configured.

Operational errors = expected, recoverable. Programmer errors = bugs, unrecoverable mid-flight. Joyent’s classic article codified this distinction; it’s still the right model.

Express/Koa pattern

In Express 4, async route handlers don’t auto-forward rejected promises. Wrap them.

const asyncHandler = (fn) => (req, res, next) => {
  Promise.resolve(fn(req, res, next)).catch(next);
};

app.get('/users/:id', asyncHandler(async (req, res) => {
  const user = await db.findUser(req.params.id);
  if (!user) throw new NotFoundError('user');
  res.json(user);
}));

// Central error middleware
app.use((err, req, res, next) => {
  logger.error({ err, url: req.url }, 'request failed');
  const status = err.status || 500;
  res.status(status).json({ error: err.message });
});

Express 5 (now stable) auto-forwards async errors. One less footgun.

Custom error classes

Use error subclasses to tell apart “expected” errors from genuine bugs.

class AppError extends Error {
  constructor(message, status = 500) {
    super(message);
    this.name = this.constructor.name;
    this.status = status;
    this.isOperational = true;
  }
}

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

// In the error middleware
if (!err.isOperational) {
  logger.fatal({ err }, 'non-operational error — restarting');
  process.exit(1);
}

Streams and EventEmitters

Streams emit 'error'. If nobody listens, Node crashes the process. Always attach:

fs.createReadStream('big.csv')
  .on('error', (err) => logger.error({ err }, 'read failed'))
  .pipe(transform)
  .on('error', (err) => logger.error({ err }, 'transform failed'));

Or better — use stream.pipeline which propagates errors cleanly:

import { pipeline } from 'node:stream/promises';

try {
  await pipeline(fs.createReadStream('in.csv'), transform, fs.createWriteStream('out.csv'));
} catch (err) {
  logger.error({ err }, 'pipeline failed');
}

Checklist

  • Wrap every async route handler so rejections reach error middleware.
  • Have a central error logger — never console.error and move on.
  • Subscribe to uncaughtException and unhandledRejection, log, then exit.
  • Run under a process manager (PM2, systemd, Docker restart policy) so crash → restart is fast.
  • Distinguish operational from programmer errors — recover from the first, crash on the second.