Implement Promise Combinators
Why Interviewers Love This Question
Implementing Promise combinators is one of the most common senior-level interview questions at Google, Meta, and Amazon. Not because you will ever rewrite them in production, but because doing it exposes whether you actually understand how Promises work under the hood.
Here is the thing most candidates get wrong: they jump straight into loops and counters without thinking about the edge cases that the spec handles. What happens with an empty array? What about non-Promise values? What if the iterable contains null? These edge cases are where interviewers separate "memorized the pattern" from "actually understands Promises."
We are going to build all four combinators from scratch. By the end, you will know exactly why each one behaves the way it does.
The Four Combinators at a Glance
| Combinator | Fulfills when | Rejects when | Result shape |
|---|---|---|---|
| Promise.all | ALL promises fulfill | ANY promise rejects (first one) | Array of fulfilled values (ordered) |
| Promise.allSettled | ALL promises settle (fulfill or reject) | Never rejects | Array of {status, value/reason} objects |
| Promise.race | First promise to settle (either way) | First promise to settle (either way) | Single value or reason |
| Promise.any | First promise to fulfill | ALL promises reject | Single value or AggregateError |
Think of four different hiring strategies:
- Promise.all is "hire the whole team or nobody." One bad candidate and you reject the entire batch.
- Promise.allSettled is "interview everyone and write a report." You get a result for each candidate regardless of outcome.
- Promise.race is "hire the first person who responds, pass or fail." Speed is everything.
- Promise.any is "hire the first person who passes." Failures are ignored unless literally everyone fails.
Before We Start: Two Critical Edge Cases
Every combinator shares two behaviors that candidates constantly forget:
- Empty iterable —
Promise.all([])fulfills with[].Promise.race([])hangs forever (returns a forever-pending promise). Know which does what. - Non-Promise values —
Promise.all([1, 2, 3])works fine. Values are wrapped withPromise.resolve()internally.
Implement Promise.all
Promise.all takes an iterable of promises, runs them concurrently, and fulfills with an array of results in the original order. If any promise rejects, the whole thing rejects immediately with that reason.
The key insight: you need a counter to track how many promises have fulfilled, not which one fulfilled last. The promises can resolve in any order, but the result array must match the input order.
function promiseAll(iterable) {
return new Promise((resolve, reject) => {
const promises = Array.from(iterable);
const results = new Array(promises.length);
let settled = 0;
if (promises.length === 0) {
resolve(results);
return;
}
promises.forEach((promise, index) => {
Promise.resolve(promise).then(
(value) => {
results[index] = value;
settled += 1;
if (settled === promises.length) {
resolve(results);
}
},
(reason) => {
reject(reason);
}
);
});
});
}
Why Each Line Matters
Array.from(iterable) — The spec says the input is an iterable, not necessarily an array. Array.from handles Sets, generators, and anything with Symbol.iterator.
Promise.resolve(promise) — Wraps non-Promise values. If someone passes [1, 2, fetch('/api')], the 1 and 2 become resolved promises.
results[index] = value — Not results.push(value). Promises resolve in unpredictable order. Using the index preserves input order in the output.
settled += 1 — We count fulfillments, not completions. A rejection short-circuits immediately via reject(reason).
Empty array check — Without this, settled === promises.length is 0 === 0, which is true, and we would resolve before the forEach even runs. Actually, forEach on an empty array simply never calls the callback, so the promise would hang forever without the early return.
A Subtle Bug Most Candidates Miss
What happens if reject is called multiple times? In the implementation above, the first rejection calls reject(reason), but other promises keep running. When they reject too, they call reject again. Luckily, the Promise spec says that calling resolve or reject on an already-settled promise is a no-op. So our implementation is actually correct without extra guarding. But if you are implementing this with custom callbacks instead of a real Promise constructor, you would need a settled flag.
Does Promise.all Cancel Remaining Promises?
No. This is one of the biggest misconceptions. When Promise.all rejects, the remaining promises continue executing. They still consume memory, CPU, and network bandwidth. Their results are just never observed.
In the native implementation, V8 still attaches handlers to every promise in the iterable. When one rejects, the returned promise is marked as rejected, but the internal handlers on other promises remain active.
If you need actual cancellation, you have to combine with AbortController:
function promiseAllWithCancel(promises, signal) {
return new Promise((resolve, reject) => {
signal?.addEventListener('abort', () => reject(signal.reason));
promiseAll(promises).then(resolve, reject);
});
}This does not magically cancel in-flight work either. Each individual promise must check the signal and bail out. Cancellation in JavaScript is always cooperative.
Implement Promise.allSettled
Promise.allSettled waits for every promise to settle (fulfill or reject) and returns an array of result objects. It never short-circuits. It never rejects.
The result objects have a consistent shape:
- Fulfilled:
{ status: "fulfilled", value: result } - Rejected:
{ status: "rejected", reason: error }
function promiseAllSettled(iterable) {
return new Promise((resolve) => {
const promises = Array.from(iterable);
const results = new Array(promises.length);
let settled = 0;
if (promises.length === 0) {
resolve(results);
return;
}
promises.forEach((promise, index) => {
Promise.resolve(promise).then(
(value) => {
results[index] = { status: "fulfilled", value };
settled += 1;
if (settled === promises.length) {
resolve(results);
}
},
(reason) => {
results[index] = { status: "rejected", reason };
settled += 1;
if (settled === promises.length) {
resolve(results);
}
}
);
});
});
}
The Key Difference from Promise.all
Notice that the rejection handler does not call reject. Instead, it records the rejection as a result and increments the counter. Both fulfillment and rejection are treated as "settled." The outer promise only resolves — it never rejects.
This is why Promise.allSettled is the safest combinator for independent operations. When you are loading a dashboard with four API calls that don't depend on each other, you want all the data you can get, even if one endpoint is down.
Implement Promise.race
Promise.race returns a promise that settles as soon as the first input promise settles. If the first one to settle fulfills, the race fulfills. If it rejects, the race rejects. The other promises are completely ignored (but still run).
This is the simplest combinator to implement:
function promiseRace(iterable) {
return new Promise((resolve, reject) => {
const promises = Array.from(iterable);
promises.forEach((promise) => {
Promise.resolve(promise).then(resolve, reject);
});
});
}
That is it. No counter. No results array. The first call to resolve or reject wins, and subsequent calls are no-ops because the Promise constructor ignores them.
The Empty Array Trap
Notice there is no empty array check. If promises is empty, forEach never runs, so resolve and reject are never called. The returned promise stays pending forever. This is spec-compliant behavior — Promise.race([]) returns a forever-pending promise.
This catches a lot of candidates off-guard. They add an early resolve([]) like Promise.all, but that is wrong for race.
When Race Makes Sense
The classic use case is timeouts:
function withTimeout(promise, ms) {
return Promise.race([
promise,
new Promise((_, reject) =>
setTimeout(() => reject(new Error('Timeout')), ms)
),
]);
}
const data = await withTimeout(fetch('/api/slow'), 5000);
If the fetch takes longer than 5 seconds, the timeout promise rejects first and the race rejects with "Timeout." The fetch still completes in the background, but the caller has already moved on.
Implement Promise.any
Promise.any is the inverse of Promise.all. It fulfills as soon as the first promise fulfills. It only rejects if ALL promises reject, and when it does, it rejects with an AggregateError containing all the rejection reasons.
function promiseAny(iterable) {
return new Promise((resolve, reject) => {
const promises = Array.from(iterable);
const errors = new Array(promises.length);
let rejectedCount = 0;
if (promises.length === 0) {
reject(new AggregateError([], "All promises were rejected"));
return;
}
promises.forEach((promise, index) => {
Promise.resolve(promise).then(
(value) => {
resolve(value);
},
(reason) => {
errors[index] = reason;
rejectedCount += 1;
if (rejectedCount === promises.length) {
reject(
new AggregateError(errors, "All promises were rejected")
);
}
}
);
});
});
}
The Mirror Image of Promise.all
Look at how symmetrical this is to Promise.all:
Promise.all: count fulfillments, short-circuit on first rejectionPromise.any: count rejections, short-circuit on first fulfillment
The only extra piece is AggregateError, which was introduced in ES2021 specifically for Promise.any. It wraps multiple errors into a single error object with an errors property that is an array.
Empty Array Behavior
With an empty array, Promise.any rejects immediately with an AggregateError. This makes sense: if there are zero promises, then zero promises can fulfill, which means "all promises rejected" is vacuously true.
Compare this with Promise.all([]) which fulfills with [] — "all promises fulfilled" is also vacuously true for an empty set. Both are logically consistent.
When Any Makes Sense
Promise.any is perfect for redundancy patterns — try multiple sources, take whichever responds first:
const content = await Promise.any([
fetchFromCDN(url),
fetchFromOrigin(url),
fetchFromCache(url),
]);
If the CDN is closest and responds first, great. If it is down, the origin or cache picks up. You only get an error if all three fail.
Empty Array Summary
This is asked in almost every interview. Memorize it:
| Combinator | Empty array behavior | Why |
|---|---|---|
| Promise.all([]) | Fulfills with [] | All zero promises fulfilled (vacuously true) |
| Promise.allSettled([]) | Fulfills with [] | All zero promises settled (vacuously true) |
| Promise.race([]) | Pending forever | No promise to settle first |
| Promise.any([]) | Rejects with AggregateError | Zero fulfillments means all rejected (vacuously true) |
Handling Non-Promise Values
All four combinators wrap non-Promise values with Promise.resolve(). This is handled by the Promise.resolve(promise) call in each implementation.
promiseAll([1, 'hello', true]).then(console.log);
// [1, 'hello', true]
promiseRace([42, Promise.resolve('slow')]).then(console.log);
// 42 — the non-Promise value wraps into an already-resolved promise,
// and its .then handler is queued first in forEach order
The Promise.resolve() wrapper also handles thenables (objects with a .then method). If you pass a jQuery deferred or a custom thenable, it gets properly adopted into the promise chain. This is per-spec behavior.
Common Mistakes
| What developers do | What they should do |
|---|---|
| Using results.push(value) instead of results[index] = value Promises resolve in unpredictable order. push() gives resolution-order results, which violates the spec. The output array must match the input array order. | Always assign by index to preserve input order |
| Forgetting to wrap values with Promise.resolve() The iterable can contain non-Promise values like numbers or strings. Without wrapping, calling .then() on a number throws a TypeError. | Always call Promise.resolve(promise) on each input |
| Using a boolean flag instead of a counter for Promise.all A flag only tells you something resolved, not that everything resolved. You need to count fulfilled promises and compare against the total length. | Use a counter that increments on each fulfillment |
| Adding resolve([]) for empty arrays in Promise.race The spec defines Promise.race([]) as a pending promise. Resolving with an empty array is the behavior of Promise.all([]), not Promise.race([]). | Let Promise.race([]) return a forever-pending promise |
| Forgetting AggregateError in Promise.any Promise.any rejects with an AggregateError, not a regular Error. The AggregateError.errors array contains every individual rejection reason in input order. | Reject with new AggregateError(errors, message) when all reject |
Key Rules
- 1Promise.all short-circuits on the FIRST rejection. Promise.any short-circuits on the FIRST fulfillment. They are mirrors.
- 2Output order always matches input order, never resolution order. Use index-based assignment, not push.
- 3Always wrap inputs with Promise.resolve() to handle non-Promise values and thenables.
- 4Promise.race([]) is forever-pending. Promise.any([]) rejects immediately. Promise.all([]) and Promise.allSettled([]) fulfill with [].
- 5Calling resolve() or reject() on an already-settled promise is a no-op. This is what makes race and all safe without extra guarding.
- 6AggregateError is only used by Promise.any. It holds an errors array with all rejection reasons in input order.