How it works
Promise.all is a small state machine built from three pieces: the result promise's resolve/reject functions, a values list, and a counter. For each element, PerformPromiseAll:
- Appends a placeholder (
undefined) to values so the element's slot is fixed by its position.
- Calls the
Promise.resolve it read once up front (GetPromiseResolve) to turn the element into a promise.
- Creates an
onFulfilled function that remembers its own [[Index]] and has an [[AlreadyCalled]] flag. When called, it stores values[index], decrements the counter, and resolves the result with the array when the counter reaches 0.
- Increments the counter and calls
nextPromise.then(onFulfilled, resultCapability.[[Reject]]).
The counter starts at 1, not 0, and is decremented once after the iterator is exhausted. The spec's note explains why: it guarantees the result is resolved only once, even if an input's then calls back before the loop has finished. It also makes all([]) fulfill with [], since the only decrement is the final one.
Three consequences are observable:
- The input is consumed synchronously and completely when
all is called. A generator runs to the end immediately; there is no concurrency limit.
then is looked up with Invoke, so an overridden then on an input promise is called with two functions.
- Rejection goes straight to the result's
reject. The first one wins; later calls are no-ops because a promise settles once. The other inputs still have a reaction attached, which is why their late rejections count as handled.
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const tracked = Promise.resolve('x');
tracked.then = function (onFulfilled, onRejected) {
console.log('then called with', [typeof onFulfilled, typeof onRejected]); // → then called with [ 'function', 'function' ]
return Promise.prototype.then.call(this, onFulfilled, onRejected);
};
console.log(await Promise.all([tracked])); // → [ 'x' ]
function* tasks() {
for (const name of ['a', 'b', 'c']) {
console.log('starting', name);
yield wait(5).then(() => name);
}
}
const all = Promise.all(tasks());
console.log('after Promise.all call');
// → starting a
// → starting b
// → starting c
// → after Promise.all call
console.log(await all); // → [ 'a', 'b', 'c' ]
allSettled and any have the same shape. allSettled builds a { status, … } record in both its fulfil and reject callbacks and counts both. any swaps the roles: it collects reasons into errors and rejects with an AggregateError when the counter reaches 0, while the first fulfilment resolves the result immediately. race has no counter at all: it attaches the result's resolve and reject to every input, and the first input to settle wins.
Edge cases
- Errors during iteration close the iterator. If reading the iterable or calling
Promise.resolve throws partway through, the result rejects with that error and the iterator is closed. Elements already started keep running.
- Late rejections are handled. After
all rejects, a later rejection from another input doesn't raise unhandledRejection, because each input has a reaction attached.
- The combinators are generic over
this. They use this as the constructor, so they work on Promise subclasses. Called with a non-constructor (Promise.all.call({}, [])), they throw a TypeError instead of returning a rejected promise.
process.on('unhandledRejection', (reason) => console.log('unhandled:', reason));
const fail = (ms, reason) => new Promise((_, reject) => setTimeout(reject, ms, reason));
try {
await Promise.all([fail(5, 'first'), fail(15, 'second')]);
} catch (reason) {
console.log('caught:', reason); // → caught: first
}
await new Promise((resolve) => setTimeout(resolve, 50));
console.log('done'); // → done
Real-world scenario
A timeout built on race has to deal with the loser, since nothing cancels it. The shape that works is: create an AbortController, give its signal to the real work, race the work against a timeout promise, and clear the timer whichever side wins. The order inside the timer callback also matters: rejecting the timeout promise before calling abort() makes the timeout reason win, because abort() synchronously rejects the work with the abort reason and race takes whichever settles first.
function withTimeout(start, ms) {
const controller = new AbortController();
let timer;
const timeout = new Promise((_, reject) => {
timer = setTimeout(() => {
reject(new Error(`timed out after ${ms} ms`)); // settle first so this reason wins the race
controller.abort();
}, ms);
});
return Promise.race([start(controller.signal), timeout]).finally(() => clearTimeout(timer));
}
const work = (ms) => (signal) =>
new Promise((resolve, reject) => {
const timer = setTimeout(resolve, ms, `finished in ${ms} ms`);
signal.addEventListener('abort', () => {
clearTimeout(timer);
reject(signal.reason);
});
});
console.log(await withTimeout(work(10), 50)); // → finished in 10 ms
await withTimeout(work(100), 20).catch((error) => console.log(error.message)); // → timed out after 20 ms
Trade-offs
all vs allSettled. all gives the fastest failure but discards the other outcomes. allSettled waits for the slowest input and always gives you everything, which suits batch jobs and reporting, but you must inspect each entry.
- No built-in concurrency limit. Because all inputs start immediately,
Promise.all(urls.map(fetch)) over thousands of items opens them all at once. Limit concurrency yourself (a worker-pool loop or a small queue) and use all over the batches.
any and race leave work running. They return as soon as there is a winner. Pair them with an AbortSignal when the extra work costs bandwidth or money.