Skip to content
System nominalUTC --:--:--Articles 041
MODULE 04 Lesson 10 / 18 30 MIN

Asynchrony

From Callbacks to Promises

Why error-first callbacks lose errors, how the promise state machine works, and what then, catch, finally and the four combinators actually return.

The first asynchronous code you write takes a callback, and it works. Then the second step needs the result of the first, and the callback goes inside the callback. By the third step you are four levels deep, and the error handling has become something you copy from the level above rather than something you design. The nesting is the visible symptom. The real damage is that errors stop behaving like errors.

Everything below ran on Node 24.11.1. Promises have been in the language since ES2015 and the combinators since ES2020, so nothing here is new syntax you have to wait for; the difficulty is that the failure modes are quiet.

A callback runs after your try block has already finished

An error-first callback passes the failure as the first argument. The convention is universal in Node and it is a convention, not a language feature, so nothing makes you check the argument and nothing makes the caller pass one.

function readRow(id, callback) {
  setTimeout(() => {
    if (id === 0) {
      callback(new Error("no row with id 0"));
      return;
    }
    callback(null, { id, total: id * 100 });
  }, 10);
}

readRow(2, (err, row) => {
  if (err) {
    console.log("failed:", err.message);
    return;
  }
  console.log("ok:", row);
});

Two things are wrong with that shape, and the second one is why promises exist. The first is that error propagation is manual: every step must check err and call the outer callback with it, so a five-step operation has five places where a missing if (err) silently discards a failure. The second is that the callback does not run inside the try block that made the call:

try {
  readRow(2, () => {
    throw new Error("thrown inside a later tick");
  });
} catch (err) {
  console.log("never reached:", err.message);
}
uncaughtException: thrown inside a later tick

By the time the timer fires, the stack that held the try has unwound. The catch block belongs to a frame that no longer exists, so the throw travels to the process instead of to your error handler. This is what people mean when they say callbacks invert control: you hand your continuation to somebody else and lose the ability to reason about where it runs. You cannot wrap a callback-based API in try/catch and expect it to work, which is why every callback-style library also shipped an error argument.

A promise is a state machine with three states and no way back

A promise is pending, fulfilled or rejected. Those are the only states, and the transition happens once. A promise that has settled, fulfilled or rejected, can never change state or change value. That single sentence explains most of the behaviour that looks strange at first: calling resolve twice does nothing the second time, a rejection that arrives after a resolution is discarded, and a handler attached an hour later still receives the original value.

The executor function you pass to new Promise runs synchronously, before the constructor returns. That surprises people in the other direction: they expect the body to be deferred.

console.log("1 before new Promise");
const p = new Promise((resolve) => {
  console.log("2 executor runs synchronously");
  resolve("done");
});
console.log("3 after new Promise: p is settled, the handler has not run");
p.then((v) => console.log("5 then runs later with:", v));
console.log("4 end of script");
1 before new Promise
2 executor runs synchronously
3 after new Promise: p is settled, the handler has not run
4 end of script
5 then runs later with: done

Handlers always run in a later microtask, never during the call that attached them, which is what makes the ordering stable. A promise that is never settled stays pending for the life of the process, and there is no timeout and no inspection method on the promise itself: the API gives you no way to ask what state a promise is in, because callers are supposed to react to settlement rather than poll for it. Building a pending promise you never resolve is how a program exits with work half done and no error at all.

One state-related trap remains. Promise.resolve(value) returns a value that is already a promise unchanged rather than wrapping it, so Promise.resolve(p) === p. Nothing breaks because of it, but "wrap this in a promise" is a no-op when the thing already is one, which is how a helper that looks like it adds a layer of safety adds nothing.

Each handler returns a new promise, and catch is a shorthand

then, catch and finally all return a promise. None of them mutates the promise you called them on, and none of them returns that same promise.

const p = Promise.resolve(1);
const q = p.then((v) => v + 1);
console.log("q === p:", q === p);

const r = Promise.reject(new Error("boom"));
console.log("r === r.catch(() => {}):", r.catch(() => {}) === r);

const kept = await Promise.resolve("kept").finally(() => "ignored");
console.log("finally value:", kept);
q === p: false
r === r.catch(() => {}): false
finally value: kept

That third line is the part of finally people guess wrong. catch(fn) is exactly then(undefined, fn), which matters because it puts the error handler in the second position and gives it one argument. finally(fn) takes no argument, runs on both paths, and passes the settled value through untouched: return a string from it and the string is ignored, not substituted. The one thing finally does propagate is its own failure. Throw inside it, or return a rejected promise from it, and the chain rejects with that error, overriding the original outcome.

What a handler returns decides the next state. Return a value and the next promise fulfils with it. Return a promise and the next promise adopts it, including its rejection. Throw inside the handler and the next promise rejects. Return nothing and the next promise fulfils with undefined, which is correct and also the source of the bug in the next section.

A rejection with no handler is not an error you will see

If a promise rejects and nothing is attached to it, Node reports an unhandled rejection and exits with code 1. Run this file and the output is the error and the stack:

Promise.reject(new Error("no handler anywhere"));
console.log("script finished");
script finished
file:///tmp/app/unhandled.mjs:1
Promise.reject(new Error("no handler anywhere"));
               ^

Error: no handler anywhere
    at file:///tmp/app/unhandled.mjs:1:16

Node.js v24.11.1

The exit code is the useful part, because it means a test run or a CI job fails rather than passing with a swallowed error. What makes these failures hard to debug is that they surface somewhere unrelated to the code that caused them: the stack points at the rejection, and the reason it was never handled is usually several layers away. process.on ("unhandledRejection", handler) turns the crash into a callback so you can log the reason and the promise, but installing it also means the process stops exiting, so it is a diagnostic tool rather than a fix.

The return-value rule is what breaks chains in production

Here is the mistake, in its natural habitat. The chain looks like it awaits the save and it does not:

const save = () => Promise.reject(new Error("save failed"));

await Promise.resolve("row")
  .then(() => {
    save();
  })
  .then(() => console.log("reached the next step"));
reached the next step
unhandledRejection: save failed

Dropping the return detaches the inner promise. The outer chain fulfils with undefined as soon as the handler finishes calling save(), so the next then runs immediately, and the rejection from save belongs to a promise that nobody is watching. Add the return and the chain waits, the rejection propagates, and a catch anywhere downstream sees it.

There is a mental model that makes the rule obvious: the value you return from a handler is the value of the next promise in the chain, and a promise is a value. Returning one transfers responsibility for settling the chain to it. Not returning one means the chain never learned there was something to wait for.

This is the strongest practical argument for async/await, which the next lesson covers. With await, waiting is the default and the mistake becomes "forgot the keyword", which the linter catches, instead of "forgot a return inside a callback", which nothing catches.

Four combinators answer four different questions

The names suggest they are variations of one thing. They are not: each one decides what happens to the other inputs when one of them fails.

MethodFulfils whenRejects whenResult
Promise.all every input fulfils the first input rejects, immediately values in input order; the rejection reason of the first failure
Promise.allSettled every input settles, either way never {status, value} or {status, reason} per input, in input order
Promise.race the first input fulfils the first input rejects whichever value or reason settled first
Promise.any the first input fulfils every input rejects the first fulfilment; otherwise an AggregateError of all reasons
const wait = (ms, value) => new Promise((resolve) => setTimeout(() => resolve(value), ms));
const fail = (ms, message) =>
  new Promise((_, reject) => setTimeout(() => reject(new Error(message)), ms));

console.log("all ok:", await Promise.all([wait(50, "slow"), wait(10, "fast")]));
try {
  await Promise.all([wait(10, "ok"), fail(20, "second failed"), fail(5, "first failed")]);
} catch (err) {
  console.log("all rejected with:", err.message);
}

const settled = await Promise.allSettled([wait(20, 1), fail(10, "nope")]);
console.log("allSettled:", settled.map((r) => r.status === "fulfilled" ? r.value : r.reason.message));

console.log("race:", await Promise.race([wait(30, "the winner"), fail(10, "rejects first")])
  .then((v) => `fulfilled ${v}`, (e) => `rejected ${e.message}`));

console.log("any:", await Promise.any([fail(10, "mirror down"), wait(30, "replica up")]));
try {
  await Promise.any([fail(10, "a"), fail(20, "b")]);
} catch (err) {
  console.log("any rejected:", err.constructor.name, err.errors.map((e) => e.message).join(", "));
}
all ok: [ 'slow', 'fast' ]
all rejected with: first failed
allSettled: [ 1, 'nope' ]
race: rejected rejects first
any: replica up
any rejected: AggregateError a, b

Read the timing in the second block: the failure that wins is the one that rejects first, not the one listed first, so all rejected with first failed even though second failed appears earlier in the array. allSettled preserves input order in its results, so you can zip it back against the inputs.

One detail the table cannot show, and the one that matters for load: all rejecting early does not cancel the remaining work. Those promises keep running to completion, and their results or failures go nowhere. If you fire eight requests and the third fails, the other seven still occupy sockets and still consume the server's time. Cancellation is a separate mechanism with its own lesson.

A short rule for picking one. Use Promise.all when every result is required and one failure means the operation failed. Use allSettled when you need a report over all inputs, such as a health check of six services where "some are down" is the answer. Use race for timeouts and for first-wins-with-no-fallbacks. Use any for redundant sources where one success is enough and failure only counts if every source failed.

Lab 30 minutes

Take this callback-style function and produce a promise-returning version of it. It must reject with the callback's error and resolve with the row.

function readRow(id, callback) {
  setTimeout(() => {
    if (id === 0) {
      callback(new Error("no row with id 0"));
      return;
    }
    callback(null, { id, total: id * 100 });
  }, 10);
}

Then copy this chain into a file without the catch, run it, and note the exit code:

readRowPromise(2)
  .then((row) => {
    readRowPromise(0);
    return row;
  })
  .then((row) => console.log("row:", row.id));

Finally, state in one sentence what each of the four combinators would do with the array [readRowPromise(1), readRowPromise(0)], and which one you would use to collect the rows that succeeded.

Show the solution and the reasoning

Wrapping a callback API is two lines, and every decision is about which argument goes to which promise method:

function readRowPromise(id) {
  return new Promise((resolve, reject) => {
    readRow(id, (err, row) => {
      if (err) reject(err);
      else resolve(row);
    });
  });
}

The else is not decoration. If you write resolve(row) on the next line without it, a call in which err is set would reject and then try to resolve undefined; the state machine discards the second call, so the visible behaviour happens to be correct, but only by accident of the state rule. Writing the guard means the code says what it means. Nothing here calls reject for a synchronous throw from readRow itself, because the executor already converts a throw into a rejection. That is the one place where the promise constructor is doing error handling for you.

The chain without a catch prints row: 2 and exits with code 1. The inner readRowPromise(0) is constructed, rejects, and has no handler, so Node reports an unhandled rejection. The outer chain is unaffected, because the unreturned promise is not part of it: this is the same detachment as the earlier example, with the extra detail that the visible chain succeeded, which is exactly why the bug survives to production. Returning the call would make the chain reject and the catch, if present, would see it.

On the combinators, with one resolving row and one rejection in the array: Promise.all rejects with no row with id 0 as soon as that rejection lands, discarding the successful row. Promise.allSettled fulfils with two result objects, one fulfilled and one rejected, and never rejects. Promise.race settles with whichever of the two settles first, which in this file is a race between two ten-millisecond timers and therefore not something to rely on. Promise.any fulfils with the successful row and ignores the rejection entirely, only producing an AggregateError if every input rejects. To collect the rows that succeeded, allSettled is the right choice, because it is the only one that turns partial failure into data instead of an exception:

const results = await Promise.allSettled([readRowPromise(1), readRowPromise(0)]);
const rows = results.filter((r) => r.status === "fulfilled").map((r) => r.value);
console.log(rows.length, "of", results.length);

If the requirement is instead "all of these must succeed or the operation failed", use Promise.all and let the first rejection out. The trade-off is that allSettled hides failures from the control flow, so a caller who forgets to inspect the statuses gets a silent partial success. That is a smaller risk than it sounds, as the status field is not optional, but it is a risk.

Progress

Mark this lesson done and it stays marked in this browser.