open atlas
↑ Back to track
Node.js, zero to senior NODE · 02 · 01

Async patterns and error handling

Callbacks, promises, and async/await are three faces of one model. Compose with Promise.all, propagate rejections instead of swallowing them, cancel with AbortController, and know that microtasks drain before the next macrotask.

NODE Middle ◷ 17 min
Level
FoundationsJuniorMiddleSenior
Already know this unit? Take a 1-minute quick check →

A request handler fetched a user, then their orders, then each order’s line items — all with await inside a for loop. It worked. It also took 4.2 seconds for a user with twelve orders, because every await blocked the next one even though the order lookups were completely independent. One reviewer rewrote the inner loop as Promise.all(orders.map(fetchLineItems)) and the handler dropped to 380ms. Same APIs, same data — the only difference was whether the code waited for things that never needed waiting on in sequence.

Callbacks: the error-first contract and its ceiling

Node’s original async style is the callback: you pass a function that runs when the work finishes. The convention is error-first — the callback’s first argument is an error (or null), the rest is the result. You check the error before touching the data, every time.

fs.readFile("config.json", "utf8", (err, data) => {
  if (err) return console.error("read failed:", err);
  const config = JSON.parse(data); // only safe once err is null
  console.log(config.port);
});

This works, but it does not compose. Sequence three dependent operations and you get the nesting that earned its own name — callback hell — where each step lives one indent deeper and error handling is copy-pasted into every level. Worse is the structural problem: inversion of control. You hand your continuation to someone else’s function and trust it to call you back exactly once, with the right arguments, on the right tick. A buggy library that calls the callback twice, or never, or synchronously when you expected async, corrupts your flow and there is nothing in the type system to stop it. Promises exist largely to take that control back.

Promises: a value you own, with combinators

Why did callbacks fail so badly that the language added an entirely new model? Because owning the result — instead of handing your continuation to someone else — is what makes composition possible.

A promise is an object representing a future value. It has three states — pending, then exactly one of fulfilled (resolved with a value) or rejected (failed with a reason) — and once settled it never changes. Because the promise is a first-class value you hold, you compose it instead of handing off control: .then chains transformations, .catch handles failures, and a rejection skips every .then until it hits a .catch. That single rejection path is the cure for copy-pasted error handling.

The real power is the combinators for running multiple promises together. Picking the wrong one is a common middle-level mistake:

CombinatorSettles whenOn rejectionUse it for
Promise.allall fulfill, or first rejectsrejects immediately (fail-fast)all-or-nothing fan-out where any failure aborts
Promise.allSettledevery promise settlesnever rejects; reports per-item statusindependent tasks where partial success is fine
Promise.racefirst to settle (fulfill or reject)rejects if the first settler rejectedtimeouts — race work against a timer
Promise.anyfirst to fulfillrejects only if all reject (AggregateError)first usable result from redundant sources

Together these four cover every fan-out shape: all for all-or-nothing, allSettled for partial success, race for “whoever is fastest”, any for “first winner”. Without the right combinator, you either lose results you already have or hide failures you need to see.

The classic trap is using Promise.all for tasks where partial success should be tolerated — one rejection then discards the results of everything else that already succeeded. If you want every result regardless of individual failures, allSettled is the correct tool; all is for when any failure genuinely invalidates the whole batch.

async/await: the same promises, read top to bottom

async/await is syntactic sugar over promises, not a separate mechanism. An async function always returns a promise — even async () => 42 returns a promise that fulfills with 42 — and await simply pauses the function until a promise settles, unwrapping its value or throwing its rejection. Because rejections become thrown errors, you handle them with ordinary try/catch, which reads far better than chained .catch.

async function loadDashboard(userId) {
  try {
    // independent — run in parallel, not in sequence
    const [user, orders] = await Promise.all([
      fetchUser(userId),
      fetchOrders(userId),
    ]);
    return { user, orders };
  } catch (err) {
    // one catch covers both rejections
    throw new Error(`dashboard load failed for ${userId}`, { cause: err });
  }
}

The single most common performance bug here is sequential await in a loop when the iterations are independent. for (const id of ids) { await fetchOne(id); } runs the requests one after another; the total time is the sum of every latency. If the calls do not depend on each other, fire them together with Promise.all(ids.map(fetchOne)) and the total time collapses to roughly the slowest single call. Sequential await is correct only when each step genuinely needs the previous step’s result.

Why this works

Do not mix await and .then carelessly on the same operation. await fetchUser().then(u => u.name) works but obscures where errors land and reads as two paradigms stapled together. Pick one per logical flow. A subtler bug: starting a promise and awaiting it later is fine and even useful for parallelism, but if you create a promise and never await or .catch it, a later rejection becomes an unhandled rejection — see error handling below.

Error handling: never swallow, decide propagate vs handle

An async error you ignore does not go away — it becomes an unhandledRejection. In modern Node a promise that rejects with no .catch (and no surrounding try/catch on its await) emits the unhandledRejection process event and, by default, crashes the process. That default is deliberate: a half-finished request in an unknown state is more dangerous than a restart.

The rule is to never swallow errors — an empty catch {} that logs nothing is how outages become unexplainable. For each failure you make one decision: handle it (you can recover — retry, fall back to a default, return a 503) or propagate it (you cannot, so re-throw and let a caller who has more context decide). A catch block that neither recovers nor re-throws is almost always a bug.

Cancellation is the other half. A request the user abandoned, or a fetch that has run too long, should be stopped — and the modern, standard tool is AbortController. You pass its signal into an async API; calling controller.abort() rejects the operation with an AbortError. Wiring it to a timer gives you a clean timeout without leaking the in-flight work:

async function fetchWithTimeout(url, ms) {
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), ms);
  try {
    return await fetch(url, { signal: controller.signal });
  } finally {
    clearTimeout(timer); // always clean up the timer
  }
}

Event-loop ordering: microtasks drain before the next macrotask

The senior question is when your callbacks actually run. The event loop processes macrotasks (timers like setTimeout, and I/O callbacks) one at a time — but after each macrotask, before moving to the next one, it fully drains the microtask queue: resolved promise callbacks, queueMicrotask, and Node’s process.nextTick. Microtasks always run before the next macrotask, and within microtasks Node runs the nextTick queue ahead of the promise queue.

That ordering explains output that surprises people:

console.log("1: sync");
setTimeout(() => console.log("2: timeout (macrotask)"), 0);
Promise.resolve().then(() => console.log("3: promise (microtask)"));
queueMicrotask(() => console.log("4: queueMicrotask (microtask)"));
process.nextTick(() => console.log("5: nextTick (microtask, first)"));
console.log("6: sync");
// 1: sync → 6: sync → 5: nextTick → 3: promise → 4: queueMicrotask → 2: timeout

Both synchronous lines run first. Then the current operation finishes and the microtask queue drains — nextTick ahead of the promise/queueMicrotask jobs — and only after the queue is empty does the setTimeout macrotask fire, even though its delay was 0. The practical payoff: a process.nextTick callback can starve I/O if it keeps scheduling more nextTick work, and a promise chain never yields to a timer mid-drain. Knowing the order is how you reason about why a “0ms” timeout still runs last.

Pick the best fit

You fan out N independent API calls. Some may fail, and you want to use every result that did succeed. Which combinator?

Quiz

A loop does `for (const id of ids) { results.push(await fetchOne(id)); }` and the calls are independent. What is the problem and the fix?

Quiz

What does this log, in order? `console.log('A'); setTimeout(() => console.log('B'), 0); Promise.resolve().then(() => console.log('C')); console.log('D');`

Recall before you leave
  1. 01
    When should you use Promise.all vs allSettled vs race vs any?
  2. 02
    Explain why a setTimeout(fn, 0) callback runs after a Promise.then callback scheduled before it.
Recap

Callbacks, promises, and async/await are three syntaxes over one asynchronous model. Callbacks use the error-first (err, data) contract but do not compose and hand control to someone else’s function; promises take that control back as a value you own, with a single rejection path through .catch and combinators — Promise.all for fail-fast all-or-nothing fan-out, allSettled when partial success counts, race for timeouts, any for the first usable result. async/await is sugar over promises: an async function always returns a promise, await unwraps or throws, and you handle failures with try/catch. The recurring performance bug is sequential await in a loop over independent work — parallelize it with Promise.all. For errors, never swallow a rejection (it becomes an unhandledRejection and can crash the process); for each failure decide whether to handle or propagate, and cancel long or abandoned work with AbortController plus a timer. Finally, the event loop drains the entire microtask queue — promises, queueMicrotask, and process.nextTick (which runs first) — after the current operation and before the next macrotask, which is why a Promise.then always beats a setTimeout(…, 0). Now when you see a slow handler in review, ask first: are those awaits in a loop truly sequential, or can they be collapsed into a Promise.all? That single question catches the most common async performance regression.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 5 done
Connected lessons

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources2
expand
  1. 01
  2. 02

Trademarks belong to their respective owners. Editorial reference only.