Skip to content

Implement Promise Combinators

advanced25 min read

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

CombinatorFulfills whenRejects whenResult shape
Promise.allALL promises fulfillANY promise rejects (first one)Array of fulfilled values (ordered)
Promise.allSettledALL promises settle (fulfill or reject)Never rejectsArray of {status, value/reason} objects
Promise.raceFirst promise to settle (either way)First promise to settle (either way)Single value or reason
Promise.anyFirst promise to fulfillALL promises rejectSingle value or AggregateError
Mental Model

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:

  1. Empty iterablePromise.all([]) fulfills with []. Promise.race([]) hangs forever (returns a forever-pending promise). Know which does what.
  2. Non-Promise valuesPromise.all([1, 2, 3]) works fine. Values are wrapped with Promise.resolve() internally.
Quiz
What does Promise.race([]) return?

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.

Quiz
In the promiseAll implementation above, what would happen if you replaced results[index] = value with results.push(value)?

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.

Quiz
What does promiseAllSettled([Promise.reject('A'), Promise.resolve('B')]) return?

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.

Quiz
If you call promiseRace([Promise.reject('X'), Promise.resolve('Y')]), what happens?

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 rejection
  • Promise.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.

Quiz
What is stored in err.errors when Promise.any([Promise.reject('A'), Promise.reject('B'), Promise.reject('C')]) rejects?

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:

CombinatorEmpty array behaviorWhy
Promise.all([])Fulfills with []All zero promises fulfilled (vacuously true)
Promise.allSettled([])Fulfills with []All zero promises settled (vacuously true)
Promise.race([])Pending foreverNo promise to settle first
Promise.any([])Rejects with AggregateErrorZero fulfillments means all rejected (vacuously true)
Quiz
Which of these returns a forever-pending promise?

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.

Quiz
What happens when you call Promise.all([1, null, undefined])?

Common Mistakes

What developers doWhat 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

Key Rules
  1. 1Promise.all short-circuits on the FIRST rejection. Promise.any short-circuits on the FIRST fulfillment. They are mirrors.
  2. 2Output order always matches input order, never resolution order. Use index-based assignment, not push.
  3. 3Always wrap inputs with Promise.resolve() to handle non-Promise values and thenables.
  4. 4Promise.race([]) is forever-pending. Promise.any([]) rejects immediately. Promise.all([]) and Promise.allSettled([]) fulfill with [].
  5. 5Calling resolve() or reject() on an already-settled promise is a no-op. This is what makes race and all safe without extra guarding.
  6. 6AggregateError is only used by Promise.any. It holds an errors array with all rejection reasons in input order.

Connected Topics