Lesson 06 / 17
Promise Combinators
Four combinators that gather multiple promises into a single promise; their differing behavior in the face of errors; the distinction between when work starts and when it is awaited.
Contents
The previous lesson turned a single promise into a value. What is a value can be put in an array; what is put in an array can be handled together. The “wait for all” function hand-written with a counter and a flag in the callbacks topic can now be written with an operator the language provides.
This lesson introduces four combinators and shows that the real difference between them is their behavior in the face of errors. The measurement stream turns here into an aggregator that reads three stations together.
When Work Starts
Reading the combinators correctly requires clearing up a misunderstanding first: combinators do not start work. The work has already started once the promises are created; the combinator only organizes the waiting.
In the examples below, the measurement source also prints the moment the request
starts. The table used throughout the course also includes the faulty D4 station.
const SOURCE = { A1: { delay: 30, value: 21.4 }, B2: { delay: 10, value: 19.8 }, C3: { delay: 20, value: 23.1 }, D4: { delay: 15, error: "sensor fault" }, }; function requestMeasurement(station) { console.log("request started:", station); return new Promise((resolve, reject) => { const record = SOURCE[station]; setTimeout(() => { if (record.error) reject(new Error(`${station}: ${record.error}`)); else resolve({ station, value: record.value }); }, record.delay); }); } const measurements = await Promise.all([requestMeasurement("A1"), requestMeasurement("B2"), requestMeasurement("C3")]); console.log("result:", measurements.map((m) => `${m.station}=${m.value}`).join(" "));
request started: A1 request started: B2 request started: C3 result: A1=21.4 B2=19.8 C3=23.1
All three requests started before Promise.all was even called, while the array was
being prepared. The waits overlapped; the result arrived once the slowest request,
A1, finished.
The result array preserves request order, not arrival order. In the callbacks topic, achieving this required placing results by index; here, the guarantee belongs to the combinator itself.
Wait for All: Promise.all
Promise.all fulfills, with a result array, once all of the given promises have
fulfilled. If any one of them rejects, it rejects immediately, with the first
rejection.
const SOURCE = { A1: { delay: 30, value: 21.4 }, B2: { delay: 10, value: 19.8 }, C3: { delay: 20, value: 23.1 }, D4: { delay: 15, error: "sensor fault" }, }; function requestMeasurement(station) { console.log("request started:", station); return new Promise((resolve, reject) => { const record = SOURCE[station]; setTimeout(() => { if (record.error) reject(new Error(`${station}: ${record.error}`)); else resolve({ station, value: record.value }); }, record.delay); }); } try { await Promise.all([requestMeasurement("A1"), requestMeasurement("D4"), requestMeasurement("C3")]); } catch (error) { console.log("all rejected:", error.message); }
request started: A1 request started: D4 request started: C3 all rejected: D4: sensor fault
Two points matter. First, the result was known at the fifteenth millisecond; A1 and C3 were never waited for. Second, and more important: not being waited for is not being canceled. The A1 and C3 requests keep going, and their results are still produced; no one just looks at them. Cancellation requires a separate mechanism and will be built in a later lesson.
This behavior is correct for jobs meaning “all or nothing”: the average of three measurements cannot be computed while one is missing.
Collect Every Result: Promise.allSettled
When a partial result is acceptable, all is the wrong tool. Promise.allSettled
never rejects; it returns a record carrying the status and result of every
promise.
const SOURCE = { A1: { delay: 30, value: 21.4 }, B2: { delay: 10, value: 19.8 }, C3: { delay: 20, value: 23.1 }, D4: { delay: 15, error: "sensor fault" }, }; function requestMeasurement(station) { console.log("request started:", station); return new Promise((resolve, reject) => { const record = SOURCE[station]; setTimeout(() => { if (record.error) reject(new Error(`${station}: ${record.error}`)); else resolve({ station, value: record.value }); }, record.delay); }); } const results = await Promise.allSettled([requestMeasurement("A1"), requestMeasurement("D4"), requestMeasurement("C3")]); for (const r of results) { if (r.status === "fulfilled") console.log("fulfilled:", r.value.station, r.value.value); else console.log("rejected:", r.reason.message); }
request started: A1 request started: D4 request started: C3 fulfilled: A1 21.4 rejected: D4: sensor fault fulfilled: C3 23.1
The faulty station did not stop the stream; two valid measurements were obtained, and the error was recorded too. In areas like a measurement network, where partial data is useful, this is the default choice.
The records’ field names are fixed: a fulfilled record has status and value; a
rejected record has status and reason.
The First to Settle: Promise.race
Promise.race takes the result of whichever promise settles first — it makes no
distinction between fulfillment and rejection.
const SOURCE = { A1: { delay: 30, value: 21.4 }, B2: { delay: 10, value: 19.8 }, C3: { delay: 20, value: 23.1 }, D4: { delay: 15, error: "sensor fault" }, }; function requestMeasurement(station) { console.log("request started:", station); return new Promise((resolve, reject) => { const record = SOURCE[station]; setTimeout(() => { if (record.error) reject(new Error(`${station}: ${record.error}`)); else resolve({ station, value: record.value }); }, record.delay); }); } const first = await Promise.race([requestMeasurement("A1"), requestMeasurement("B2"), requestMeasurement("C3")]); console.log("race result:", first.station, first.value); try { await Promise.race([requestMeasurement("A1"), requestMeasurement("D4")]); } catch (error) { console.log("race rejected:", error.message); }
request started: A1 request started: B2 request started: C3 race result: B2 19.8 request started: A1 request started: D4 race rejected: D4: sensor fault
In the second race, the promise that settled fastest was the faulty station’s; race
counted it as the result too. This is why race does not mean “take the fastest
healthy response.”
One warning is needed: promises that lose the race keep living. If a promise that
never settles is put inside race, that promise and everything it closes over stays
in memory for the life of the program. This is one of the leak types covered in the
memory topic.
The First to Succeed: Promise.any
Promise.any ignores rejections and returns the result of the first to fulfill. If
all of them reject, it rejects with an aggregate error containing all the reasons.
const SOURCE = { A1: { delay: 30, value: 21.4 }, D4: { delay: 15, error: "sensor fault" }, }; function requestMeasurement(station) { console.log("request started:", station); return new Promise((resolve, reject) => { const record = SOURCE[station]; setTimeout(() => { if (record.error) reject(new Error(`${station}: ${record.error}`)); else resolve({ station, value: record.value }); }, record.delay); }); } const firstSuccessful = await Promise.any([requestMeasurement("A1"), requestMeasurement("D4")]); console.log("any result:", firstSuccessful.station, firstSuccessful.value); try { await Promise.any([requestMeasurement("D4")]); } catch (error) { console.log("any rejected:", error.constructor.name); console.log("errors inside:", error.errors.map((e) => e.message).join(", ")); }
request started: A1 request started: D4 any result: A1 21.4 request started: D4 any rejected: AggregateError errors inside: D4: sensor fault
The faulty station rejected at the fifteenth millisecond; any ignored it and waited
for the valid response at the thirtieth millisecond. The aggregate error object
carries the individual reasons in an errors array — so no error information is lost.
This combinator is the direct counterpart of the redundancy pattern that requests the same data from multiple sources and uses whichever works first.
The Selection Criterion
| Combinator | Fulfills | Rejects | Fits when |
|---|---|---|---|
all |
If all fulfill | On the first rejection | Every part is required |
allSettled |
Always | Never | A partial result is acceptable |
race |
If the first to settle fulfills | If the first to settle rejects | Setting a time limit |
any |
On the first to fulfill | If all reject | Redundant sources |
All four take the same input shape and return a single promise; this is why they can
be nested. A result chosen with any from one set of stations can be combined
with another set’s result inside all.
Summary
- Combinators do not start work; the work has started once the promises are created, and the combinator only organizes the waiting.
allgives the result array in request order and rejects immediately on the first rejection; requests that are not waited for are not canceled, they keep going.allSettlednever rejects; it returns a status-and-result record for every promise.racelooks at whichever settles first and does not distinguish success from failure;anypicks the first to fulfill and, if all reject, carries the reasons in an aggregate error.- Promises that lose the race and never settle continue to occupy memory.
Next Step
The combinators built the structure of waiting, but they did not change how the code
reads: results are still processed inside callbacks. The next lesson introduces the
syntactic layer that expresses the same chains with sequential-looking syntax —
async and await. The same lesson shows the common bug where this syntax leads to
unintentional sequential waiting, without the writer noticing, and fixes it with the
combinators built in this lesson.
To keep your progress and take notes, Log in
My notes
Log in to take notes.