Writing Promise.all by hand is a classic advanced JavaScript question because the happy path takes a minute, while the details (ordering, fail-fast, empty input, plain values) separate candidates who have used promises from those who understand them. This note pins down the exact semantics, implements them, reuses the same skeleton for allSettled, race and any, and finishes with a concurrency-limited pool, a very common follow-up.
What Promise.all guarantees
- It accepts any iterable: arrays, Sets, generators, even strings (
Promise.all('ab')fulfills with['a', 'b']). - It returns one promise that fulfills with an array of values in input order, whichever item settles first.
- It fails fast: the first rejection rejects the result with that reason. The other operations are not canceled; their outcomes are ignored.
- Items that are not promises count as already fulfilled, and thenables are adopted. Each item goes through
Promise.resolve. - An empty iterable fulfills with
[]straight away. - A non-iterable argument returns a rejected promise with a
TypeError. It never throws synchronously.
const sleep = (ms, value) => new Promise((resolve) => setTimeout(resolve, ms, value));
Promise.all([sleep(30, 'slow'), sleep(10, 'fast'), 42]).then(console.log);
// [ 'slow', 'fast', 42 ]
Promise.all([]).then(console.log); // []
Promise.all(5).catch((e) => console.log(e.name)); // TypeErrorImplementing Promise.all
function promiseAll(iterable) {
return new Promise((resolve, reject) => {
const results = [];
let total = 0;
let fulfilled = 0;
for (const item of iterable) {
const index = total++;
Promise.resolve(item).then((value) => {
results[index] = value;
if (++fulfilled === total) resolve(results);
}, reject);
}
if (total === 0) resolve(results);
});
}Walk the interviewer through the decisions:
- Everything runs inside the executor. If
iterableis not iterable,for...ofthrows, and a throw inside the executor rejects the promise, exactly like the native version. - Each item captures its own
index(aconstper iteration), so its value lands in its own slot. That is what preserves input order. Promise.resolve(item)normalizes plain values and thenables into real promises.- Counting is safe while
totalis still growing, because.thencallbacks always run asynchronously as microtasks. The loop has counted every item before the first callback runs. (See the event loop note for why.) rejectis passed directly. The first rejection wins; later calls toresolveorrejectare ignored because a promise settles only once. As a bonus, every item now has a rejection handler, so later failures never show up as unhandled rejections.- The empty case is checked after the loop, otherwise the promise would never settle.
Gotcha
Two classic bugs:
results.push(value)records completion order, and checkingresults.length === totalbreaks because assigningresults[2]first makeslength3 while slots 0 and 1 are still empty. Count fulfillments instead.
Interview tip
Say the invariant out loud: “each item writes to its own index, and I resolve when the fulfilled count equals the total.” It shows you designed the solution rather than recalled it.
Try it yourself: Promise.all challenge.
allSettled, race and any
The siblings reuse the same skeleton with different settle conditions.
allSettled: never rejects
async function promiseAllSettled(iterable) {
const wrapped = [...iterable].map((item) =>
Promise.resolve(item).then(
(value) => ({ status: 'fulfilled', value }),
(reason) => ({ status: 'rejected', reason }),
),
);
return promiseAll(wrapped);
}Each item becomes a promise that always fulfills with a status object, so promiseAll can never reject. The async keyword matters: spreading a non-iterable throws synchronously, and async turns that into a rejected promise, matching the native behavior.
promiseAllSettled([sleep(10, 'ok'), Promise.reject(new Error('nope'))])
.then((results) => console.log(results.map((r) => r.status)));
// [ 'fulfilled', 'rejected' ]race: the first to settle wins
function promiseRace(iterable) {
return new Promise((resolve, reject) => {
for (const item of iterable) {
Promise.resolve(item).then(resolve, reject);
}
});
}Every item gets both callbacks; whichever settles first decides, and the rest are no-ops. Plain values settle almost immediately, so promiseRace([sleep(10, 'a'), 'b']) fulfills with 'b'. With an empty iterable, nothing ever settles, so the promise stays pending forever, which is the specified behavior.
any: the first to fulfill wins
function promiseAny(iterable) {
return new Promise((resolve, reject) => {
const errors = [];
let total = 0;
let rejected = 0;
const fail = () => reject(new AggregateError(errors, 'All promises were rejected'));
for (const item of iterable) {
const index = total++;
Promise.resolve(item).then(resolve, (reason) => {
errors[index] = reason;
if (++rejected === total) fail();
});
}
if (total === 0) fail();
});
}This is promiseAll inverted: resolve on the first fulfillment, collect reasons by index, and reject with an AggregateError once every item has rejected. Empty input rejects immediately.
promiseAny([Promise.reject(new Error('a')), sleep(20, 'b')]).then(console.log); // b
promiseAny([Promise.reject(1), Promise.reject(2)])
.catch((e) => console.log(e.name, e.errors)); // AggregateError [ 1, 2 ]| Method | Fulfills when | Rejects when | Empty input |
|---|---|---|---|
all |
every item fulfills | the first item rejects | fulfills with [] |
allSettled |
every item settles | never (only for bad input) | fulfills with [] |
race |
the first item to settle fulfills | the first item to settle rejects | pending forever |
any |
the first item fulfills | every item rejects | rejects with AggregateError |
Bonus: a concurrency-limited pool
“Run 100 uploads, at most 3 at a time” is the usual follow-up. The catch: Promise.all starts nothing. Once you hold a promise, its work is already running. To limit concurrency, accept task functions that start the work when called.
async function promisePool(tasks, limit) {
const results = new Array(tasks.length);
let next = 0;
let failed = false;
async function worker() {
while (next < tasks.length && !failed) {
const index = next++;
try {
results[index] = await tasks[index]();
} catch (err) {
failed = true; // stop picking up new tasks
throw err;
}
}
}
const size = Math.min(limit, tasks.length);
await Promise.all(Array.from({ length: size }, worker));
return results;
}limit workers share one next counter. That is safe because JavaScript runs one piece of code at a time: next++ happens synchronously between awaits, so two workers never grab the same task. Results are stored by index, Promise.all over the workers gives fail-fast behavior, and the failed flag stops new tasks from starting after an error (tasks already in flight still finish).
const task = (id, ms) => () => sleep(ms, id);
promisePool([task('a', 30), task('b', 10), task('c', 10), task('d', 5)], 2)
.then(console.log);
// [ 'a', 'b', 'c', 'd' ], with at most 2 tasks running at any momentNote
RxJS offers the same idea for streams:
mergeMap(project, concurrent)caps how many inner subscriptions run at once. See switchMap vs mergeMap vs concatMap vs exhaustMap.
Edge cases interviewers probe
- Ordering. Results follow input order, never completion order.
- No cancellation. A rejection does not stop the other operations. To really cancel, pass an
AbortSignalto eachfetchand callcontroller.abort()in thecatch. - Unhandled rejections. Awaiting an array of promises one by one (
for (const p of promises) await p) is not equivalent: if a later promise rejects while you are still awaiting an earlier one, it has no handler yet and triggers an unhandled rejection (which crashes Node by default).Promise.allattaches handlers to everything up front. - Plain values and thenables. Both work because of
Promise.resolve;[undefined]fulfills with[undefined]. - Strings are iterable.
Promise.all('ab')is valid and fulfills with['a', 'b']. - Bad input.
Promise.all(5)andPromise.all(null)return rejected promises with aTypeError. - Subclassing. The native methods call
this.resolve, soMyPromise.allproducesMyPromiseinstances. Standalone polyfills usually skip that, and it is fine to say so.
The interview answer
“Promise.all takes an iterable and returns one promise. I wrap each item in Promise.resolve so plain values and thenables work, and I store each result at its original index, so the output keeps input order whatever the timing. I count fulfillments and resolve when the count reaches the total. The first rejection rejects the whole thing, and since a promise settles only once, anything after that is ignored. Empty input resolves to an empty array right away, and a non-iterable rejects with a TypeError because the loop runs inside the executor.
The siblings are variations on that skeleton. allSettled maps each item to a status object and never rejects, race settles with whichever item settles first, and any fulfills with the first success or rejects with an AggregateError. None of them cancel the losers; for that you need an AbortSignal. And if you ask me to limit concurrency, I’d take task functions instead of promises and run N workers pulling from a shared index.”