Async Error Handling That Holds Up

Where errors go in each async style, and the two process-level events that save you.

Every async style has a different "where does the error surface" answer, and mixing them is where bugs hide.

StyleError appears asCatch with
Error-first callbackfirst callback argumentif (err) ...
Promiserejection.catch()
async/awaitthrown exceptiontry/catch
EventEmitter'error' event.on('error', ...)
// A thrown error inside an async function becomes a rejected promise.
async function load() {
  throw new Error('boom'); // does NOT throw synchronously
}
load().catch((e) => console.log('caught:', e.message));
// A bare `load();` here would be an UNHANDLED rejection.

The two safety nets

An EventEmitter 'error' with no listener throws and crashes the process. An unhandled promise rejection triggers process.on('unhandledRejection') and, by default in modern Node, also crashes. Wire both up: log with context, then in the uncaught/unhandled case, exit and let your supervisor restart — a process in an unknown state should not keep serving traffic.

Example

Example · javascript
// Production-grade last-resort handlers plus a graceful-shutdown path.
const server = require('node:http').createServer(handler);

let shuttingDown = false;
async function shutdown(code, reason) {
  if (shuttingDown) return;
  shuttingDown = true;
  console.error('shutting down:', reason);
  // Stop accepting new work, drain in-flight requests, then exit.
  server.close(() => process.exit(code));
  // Hard cap so a stuck connection cannot block the exit forever.
  setTimeout(() => process.exit(code), 10_000).unref();
}

process.on('unhandledRejection', (reason) => {
  console.error('unhandledRejection', reason);
  shutdown(1, 'unhandledRejection');
});

process.on('uncaughtException', (err) => {
  console.error('uncaughtException', err);
  shutdown(1, 'uncaughtException');
});

// SIGTERM from an orchestrator (k8s, systemd) => clean drain, exit 0.
process.on('SIGTERM', () => shutdown(0, 'SIGTERM'));

function handler(req, res) { res.end('ok'); }
server.listen(3000);

When to use it

  • A team migrates a legacy codebase from callback-style `fs` calls to `fs/promises` to eliminate error-first boilerplate and enable `async/await`.
  • A library author decides to expose a dual API -- callback for backward compatibility and a promise-returning override -- using `util.promisify.custom`.
  • A code-review checklist flags any new function with the error-first pattern as a change request, enforcing the team standard of returning Promises.

More examples

Error-first callback API

The classic Node.js error-first callback pattern -- the first argument is always an error or `null`.

Example · js
const fs = require('fs');

fs.readFile('data.txt', 'utf8', (err, data) => {
  if (err) {
    console.error('Error:', err.message);
    return;
  }
  console.log(data);
});

Promise-based equivalent

The same file read using the promise-based `fs/promises` API -- cleaner, composable, and awaitable.

Example · js
const { readFile } = require('fs/promises');

readFile('data.txt', 'utf8')
  .then(data => console.log(data))
  .catch(err  => console.error('Error:', err.message));

Async/await equivalent with error handling

Wraps the promise in `async/await` with `try/catch` for the most readable and maintainable error-handling style.

Example · js
const { readFile } = require('fs/promises');

async function load(file) {
  try {
    const data = await readFile(file, 'utf8');
    return data;
  } catch (err) {
    console.error(`Failed to read ${file}:`, err.message);
    throw err;
  }
}

load('data.txt').then(console.log);

Discussion

  • Be the first to comment on this lesson.