Ch. 1 · JavaScript

Write Promise.all (and Friends) From Scratch

Implement Promise.all with native semantics, then allSettled, race, any and a concurrency-limited pool, plus the edge cases interviewers probe.

~6 min readadvanced

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)); // TypeError
JavaScript

Implementing 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);
  });
}
JavaScript

Walk the interviewer through the decisions:

  1. Everything runs inside the executor. If iterable is not iterable, for...of throws, and a throw inside the executor rejects the promise, exactly like the native version.
  2. Each item captures its own index (a const per iteration), so its value lands in its own slot. That is what preserves input order.
  3. Promise.resolve(item) normalizes plain values and thenables into real promises.
  4. Counting is safe while total is still growing, because .then callbacks always run asynchronously as microtasks. The loop has counted every item before the first callback runs. (See the event loop note for why.)
  5. reject is passed directly. The first rejection wins; later calls to resolve or reject are 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.
  6. 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 checking results.length === total breaks because assigning results[2] first makes length 3 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);
}
JavaScript

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' ]
JavaScript

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);
    }
  });
}
JavaScript

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();
  });
}
JavaScript

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 ]
JavaScript
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;
}
JavaScript

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 moment
JavaScript

Note

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 AbortSignal to each fetch and call controller.abort() in the catch.
  • 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.all attaches 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) and Promise.all(null) return rejected promises with a TypeError.
  • Subclassing. The native methods call this.resolve, so MyPromise.all produces MyPromise instances. 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.”

More in JavaScript

read ✓JavaScript · mid

Closures, Scope & the Classic setTimeout Loop

What lexical scope and closures really are, why the var + setTimeout loop prints 3 3 3, three ways to fix it, and where closures earn their keep in real code.

~6 min readread →
read ✓JavaScript · hard

The Event Loop: Microtasks vs Macrotasks

How the call stack, task queue and microtask queue fit together, where rendering happens, how async/await schedules work, and output puzzles with answers.

~7 min readread →
esc