Skip to content
academia.sh

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.
  • all gives 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.
  • allSettled never rejects; it returns a status-and-result record for every promise.
  • race looks at whichever settles first and does not distinguish success from failure; any picks 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.

Start typing to search.

↑↓ Esc navigate · open · close