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.
| Method | Fulfils when | Rejects when | Result |
|---|---|---|---|
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.
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.