Promise combinators
Four functions take a collection of promises and give back a single promise, and choosing between them is choosing what done means. Promise.all is done when they have all succeeded, and fails as soon as one fails. Promise.race is done when the first one settles, whichever way it went. Promise.any is done when the first one succeeds, and fails only if they all fail. And Promise.allSettled is done when they have all settled, and never fails.
all is the one you want most days, allSettled is for when a failure is a result rather than a disaster, any is for redundancy, and race is for timeouts and very little else. Every one of them takes an iterable rather than just an array, and none of them changes anything about the promises you passed in.
Create programs/promise-combinators.test.ts for this reference and keep it open. Start it with the assertion helpers used on this page:
import {
assert,
assertEquals,
assertFalse,
assertRejects,
assertStrictEquals,
} from "@std/assert";
Below the import, add two helpers the whole page leans on: after fulfils with a value after a delay, and failAfter rejects with an Error after one.
function after<T>(ms: number, value: T): Promise<T> {
return new Promise((resolve) => setTimeout(() => resolve(value), ms));
}
function failAfter(ms: number, message: string): Promise<never> {
return new Promise((_, reject) =>
setTimeout(() => reject(new Error(message)), ms)
);
}
Real timers make the orderings on this page real, and they make Deno's test runner fussy in an instructive way: it reports a timer still pending when a test ends, so several tests below wait out timers they deliberately abandoned. Follow the page as you add and revise the runnable examples below the helpers.
all, for when you need every result
Deno.test("all, for when you need every result", async () => {
assertEquals(await Promise.all([after(0, "a"), after(0, "b")]), ["a", "b"]);
const error = await assertRejects(
() => Promise.all([after(10, "a"), failAfter(0, "one failed")]),
Error,
);
assertStrictEquals(error.message, "one failed");
await after(15, null);
});
Check programs/promise-combinators.test.ts
running 1 test from ./programs/promise-combinators.test.ts
all, for when you need every result ... ok (23ms)
ok | 1 passed | 0 failed (25ms)
An array of promises in, a promise for an array out. On success you get every value; on failure you get the first rejection reason, and the successes are discarded. The trailing wait is the sanitizer note paid for the first time: all stopped waiting for after(10, "a") the moment the fast failure landed, and the timer it abandoned still had to finish before the test could end, which is this page's biggest lesson arriving early.
the other three, in one look
race and any are the pair people reach for interchangeably. The same two promises, one failing fast and one succeeding slowly; predict what race hands back:
Deno.test("the other three, in one look", async () => {
const mixed = () => [failAfter(0, "fast failure"), after(10, "slow success")];
assertStrictEquals(await Promise.race(mixed()), "slow success");
});
Check programs/promise-combinators.test.ts
running 2 tests from ./programs/promise-combinators.test.ts
...
the other three, in one look ... FAILED (2ms)
ERRORS
the other three, in one look => ./programs/promise-combinators.test.ts:32:6
error: Error: fast failure
setTimeout(() => reject(new Error(message)), ms)
^
FAILURES
the other three, in one look => ./programs/promise-combinators.test.ts:32:6
FAILED | 1 passed | 1 failed (30ms)
error: Test failed
race rejected, and the test died on the fast failure. race cares about settling, and a rejection settles, so the fast failure wins. any cares about succeeding, so it ignores the same failure entirely and waits for the slow success. allSettled reports both, in input order, and does not reject. One input, three answers, and the comparison is worth reading twice, because race and any behave oppositely on exactly this input. Correct the prediction and run all three:
Deno.test("the other three, in one look", async () => {
const mixed = () => [failAfter(0, "fast failure"), after(10, "slow success")];
const raceError = await assertRejects(() => Promise.race(mixed()), Error);
assertStrictEquals(raceError.message, "fast failure");
assertStrictEquals(await Promise.any(mixed()), "slow success");
const settled = await Promise.allSettled(mixed());
assertEquals(settled.map((result) => result.status), [
"rejected",
"fulfilled",
]);
});
the other three, in one look ... ok (25ms)
the result order is the input order
fast settles twenty milliseconds before slow. Predict the array:
Deno.test("the result order is the input order", async () => {
assertEquals(
await Promise.all([after(20, "slow"), after(0, "fast")]),
["fast", "slow"],
);
});
Check programs/promise-combinators.test.ts
running 3 tests from ./programs/promise-combinators.test.ts
...
the result order is the input order ... FAILED (28ms)
ERRORS
the result order is the input order => ./programs/promise-combinators.test.ts:48:6
error: AssertionError: Values are not equal.
[Diff] Actual / Expected
[
- "slow",
"fast",
+ "slow",
]
FAILURES
the result order is the input order => ./programs/promise-combinators.test.ts:48:6
FAILED | 2 passed | 1 failed (78ms)
error: Test failed
The array you get back is positional, not chronological, so the result of Promise.all lines up with the array you passed in, and you can destructure it knowing which is which, which the closing step leans on. The same holds for allSettled; only race and any are about timing, and neither returns an array. Correct the prediction to ["slow", "fast"]:
the result order is the input order ... ok (21ms)
a non-promise is passed straight through
Deno.test("a non-promise is passed straight through", async () => {
assertEquals(
await Promise.all([1, Promise.resolve(2), "three"]),
[1, 2, "three"],
);
});
a non-promise is passed straight through ... ok (92µs)
Every value in the iterable goes through Promise.resolve, the accept-either rule from the promises page's resolving is not fulfilling, so plain values are allowed and arrive unchanged. Convenient when some of your inputs are cached and some need fetching, because you do not have to wrap the cached ones.
short-circuiting stops the waiting, not the work
all rejects the moment boom fails. Predict what the log holds after the twenty-millisecond wait:
Deno.test("short-circuiting stops the waiting, not the work", async () => {
const log: string[] = [];
function tracked(ms: number, name: string, fail = false): Promise<string> {
return new Promise((resolve, reject) =>
setTimeout(() => {
log.push(`finished ${name}`);
if (fail) reject(new Error(name));
else resolve(name);
}, ms)
);
}
await assertRejects(
() => Promise.all([tracked(0, "boom", true), tracked(10, "carries on")]),
Error,
);
log.push("all rejected");
await after(20, null);
assertEquals(log, ["finished boom", "all rejected"]);
});
Check programs/promise-combinators.test.ts
running 5 tests from ./programs/promise-combinators.test.ts
...
short-circuiting stops the waiting, not the work ... FAILED (30ms)
ERRORS
short-circuiting stops the waiting, not the work => ./programs/promise-combinators.test.ts:64:6
error: AssertionError: Values are not equal.
[Diff] Actual / Expected
[
"finished boom",
"all rejected",
- "finished carries on",
]
FAILURES
short-circuiting stops the waiting, not the work => ./programs/promise-combinators.test.ts:64:6
FAILED | 4 passed | 1 failed (101ms)
error: Test failed
The second task finished anyway, and that extra diff line is the most important fact in the entry. Promise.all rejected as soon as the first promise did, which is the short-circuit, and then, ten milliseconds later, finished carries on arrived. Nothing was cancelled, because a promise is not a handle on work: it is a notification about work, and dropping the notification does not reach back and stop anything. The request still completes, the file still gets written, the timer still fires. race, any, and all all short-circuit, and all have this property. If you need work to actually stop, you need something built for it, which in Deno is an AbortSignal passed into the operation, and a different subject. Correct the prediction:
Deno.test("short-circuiting stops the waiting, not the work", async () => {
const log: string[] = [];
function tracked(ms: number, name: string, fail = false): Promise<string> {
return new Promise((resolve, reject) =>
setTimeout(() => {
log.push(`finished ${name}`);
if (fail) reject(new Error(name));
else resolve(name);
}, ms)
);
}
await assertRejects(
() => Promise.all([tracked(0, "boom", true), tracked(10, "carries on")]),
Error,
);
log.push("all rejected");
await after(20, null);
assertEquals(log, ["finished boom", "all rejected", "finished carries on"]);
});
short-circuiting stops the waiting, not the work ... ok (23ms)
an empty input settles four different ways
Deno.test("an empty input settles four different ways", async () => {
assertEquals(await Promise.all([]), []);
assertEquals(await Promise.allSettled([]), []);
const error = await assertRejects(() => Promise.any([]), AggregateError);
assertEquals(error.errors, []);
let raceSettled = false;
Promise.race([]).then(() => {
raceSettled = true;
});
await after(10, null);
assertFalse(raceSettled);
});
an empty input settles four different ways ... ok (11ms)
Four functions, one empty array, four answers, each following from what the function means. all fulfils immediately, because everything succeeded, vacuously, the same vacuous truth the transforming arrays page pinned with every on an empty array. allSettled fulfils with [], because everything settled. any rejects, with an AggregateError holding no errors, because nothing succeeded. And race never settles at all, because nothing will ever be first. That last one is a real hazard: await Promise.race([]) is a hang rather than an error, and an empty array is easily the result of a filter or an empty page of results, so check the length before racing. The pinned assertFalse after a ten-millisecond wait is as close as a test can get to proving never.
AggregateError collects every reason
Deno.test("AggregateError collects every reason", async () => {
const error = await assertRejects(
() => Promise.any([failAfter(0, "first"), failAfter(0, "second")]),
AggregateError,
);
assert(error instanceof Error);
assertStrictEquals(error.message, "All promises were rejected");
assertEquals(error.errors.map((e: Error) => e.message), ["first", "second"]);
});
AggregateError collects every reason ... ok (2ms)
When Promise.any fails, one reason is not enough, so it rejects with an AggregateError: a real Error subclass whose .errors array holds every rejection reason in input order, and whose message is the fixed string All promises were rejected. This is the only place in the standard library that produces one, the fact the designing error types page filed in AggregateError, and worth recognising in a stack trace, because the message tells you nothing about why any individual attempt failed. The reasons are in .errors and nowhere else.
allSettled results narrow on status
Deno.test("allSettled results narrow on status", async () => {
const results = await Promise.allSettled([
after(0, "kept"),
failAfter(0, "dropped"),
]);
const values: string[] = [];
const reasons: string[] = [];
for (const result of results) {
if (result.status === "fulfilled") {
values.push(result.value);
} else if (result.reason instanceof Error) {
reasons.push(result.reason.message);
}
}
assertEquals(values, ["kept"]);
assertEquals(reasons, ["dropped"]);
assertEquals(
results.filter((r) => r.status === "fulfilled").map((r) => r.value),
["kept"],
);
});
allSettled results narrow on status ... ok (2ms)
Each element is either a {status: "fulfilled", value} or a {status: "rejected", reason}, a discriminated union, so checking status hands you the right property with no cast, the machinery from the unions and narrowing page. It narrows through a filter too, the inferred type predicate from the transforming arrays page's filter narrows the element type doing real work.
One place the types stop, and it is worth knowing because it is inconsistent with the rest of the language. Put this in a scratch file programs/reason-is-any.ts:
const results = await Promise.allSettled([
Promise.reject(new Error("failed")),
]);
const first = results[0];
if (first.status === "rejected") {
console.log(first.reason.whatever.deeply.nested);
}
deno check programs/reason-is-any.ts passes:
Check programs/reason-is-any.ts
That is the whole capture: three imaginary properties read off a rejection reason, and no complaint, because reason is typed any. Deno goes to the trouble of typing a catch binding as unknown, from the errors and exceptions page's the caught value is unknown, and this path keeps any. Treat a reason as unknown yourself and narrow before reading .message, which is what the instanceof in the test above does. Delete the scratch file.
race is for timeouts, and it does not stop the work
Deno.test("race is for timeouts, and it does not stop the work", async () => {
function timeout<T>(ms: number, work: Promise<T>): Promise<T> {
return Promise.race([work, failAfter(ms, `timed out after ${ms}ms`)]);
}
const finished: string[] = [];
const work = after(20, "payload").then((value) => {
finished.push(`work finished with ${value}`);
return value;
});
const error = await assertRejects(() => timeout(5, work), Error);
assertStrictEquals(error.message, "timed out after 5ms");
assertEquals(finished, []);
await after(30, null);
assertEquals(finished, ["work finished with payload"]);
});
race is for timeouts, and it does not stop the work ... ok (38ms)
Three lines of timeout, and the only use of race most code ever needs: the work and a timer compete, and whichever settles first decides. Be precise about what this buys. A timeout stops you waiting; it does not stop the operation, and the two finished measurements prove it, empty when the timeout fired and holding the payload twenty-five milliseconds later. If the operation holds a connection or writes a file, it still does. So a timeout bounds your own latency rather than cancelling anything, and for actual cancellation you pass an AbortSignal to something that accepts one.
concurrency is decided by when you start
Deno.test("concurrency is decided by when you start", async () => {
const starts: string[] = [];
async function tracedWork(id: string): Promise<string> {
starts.push(`start ${id}`);
await after(5, null);
starts.push(`end ${id}`);
return id;
}
await tracedWork("a");
await tracedWork("b");
assertEquals(starts, ["start a", "end a", "start b", "end b"]);
starts.length = 0;
await Promise.all([tracedWork("a"), tracedWork("b")]);
assertEquals(starts, ["start a", "start b", "end a", "end b"]);
starts.length = 0;
const first = tracedWork("a");
const second = tracedWork("b");
await first;
await second;
assertEquals(starts, ["start a", "start b", "end a", "end b"]);
});
concurrency is decided by when you start ... ok (26ms)
Three versions of the same two operations, with every start and end logged. The first is sequential, because await stopped the function between the calls, the loop shape the async functions page measured in an await in a loop body is sequential. The second is concurrent, with both starts before either end. The third is the lesson: it uses no combinator at all, and its log is identical to Promise.all's, because both operations were started before anything was awaited, and awaiting them one at a time afterwards changes nothing. So Promise.all does not make things concurrent. Calling the functions makes them concurrent, since a promise-returning function's body starts immediately, from the promises page's the body starts now, the settlement arrives later. Promise.all is how you collect results already in flight, and the readable way to do it, but the concurrency happened one line earlier.
a tuple out, and batches when all is too eager
Deno.test("a tuple out, and batches when all is too eager", async () => {
function loadName(): Promise<string> {
return after(0, "Ada");
}
function loadVisits(): Promise<number> {
return after(0, 3);
}
const [name, visits] = await Promise.all([loadName(), loadVisits()]);
assertStrictEquals(name.toUpperCase(), "ADA");
assertStrictEquals(visits + 1, 4);
async function inBatches<T>(
tasks: Array<() => Promise<T>>,
size: number,
): Promise<T[]> {
const results: T[] = [];
for (let start = 0; start < tasks.length; start += size) {
const batch = tasks.slice(start, start + size);
results.push(...await Promise.all(batch.map((task) => task())));
}
return results;
}
const running: number[] = [];
let concurrent = 0;
const tasks = [1, 2, 3, 4, 5].map((n) => async () => {
concurrent++;
running.push(concurrent);
await after(5, null);
concurrent--;
return n;
});
assertEquals(await inBatches(tasks, 2), [1, 2, 3, 4, 5]);
assertStrictEquals(Math.max(...running), 2);
});
a tuple out, and batches when all is too eager ... ok (23ms)
Two closing shapes. Promise.all over an array of different types gives a tuple, so name is a string and visits is a number with no annotation and no cast, taken apart positionally by the array pattern from the destructuring page, which makes it the natural way to fetch several unrelated things at once and one of the places TypeScript is quietly excellent. And inBatches is the restraint all does not have: Promise.all(urls.map(fetch)) over ten thousand URLs opens ten thousand connections, so the batcher takes an array of functions returning promises rather than an array of promises, because a promise has already started and there would be nothing left to batch. The counter pins it, with never more than two tasks in flight, and the await inside its loop is the deliberate kind.
In practice
- Use
Promise.allwith destructuring for several independent results that must all succeed. - Use
allSettledwhen partial success is an outcome, and treat each rejection reason asunknown. - Use
anyfor redundant sources where the first success wins. - Use
racefor timeouts, remembering that losing operations continue to run. - Batch a large input using functions that start work, not promises that have already started.
- Check for an empty input before calling
race, because an empty race never settles.