ESC

Type to search the knowledge base.

Promise.race Polyfill

Implement Promise.race: first settlement wins, including a fast rejection. Timeouts, cancellation myths, and the empty-iterable hang.

intermediate4 min read
  • javascript
  • promises
  • polyfill
  • async

People hear “race” and implement first success. A fast rejection then gets ignored while a slow success “wins,” which is Promise.any, not Promise.race. The other interview miss is using race as cancellation: wrap fetch vs setTimeout, assume the loser stopped, leak the timer, and leave the request in flight.

Promise.race settles with the first input that settles, fulfillment or rejection. Empty iterable: the returned promise never settles. That is in the spec. It is not a reject, and it is not [].

Standalone myPromiseRace. Do not mutate Promise.race unless they ask.

Key takeaways

  • First settlement wins, including a rejection that finished first.
  • Promise.resolve wraps thenables and values. Already-settled inputs queue in iteration order.
  • race([]) stays pending forever. Do not special-case it to reject.
  • Race does not cancel the losers. A timeout pattern still needs clearTimeout and, for IO, AbortController.
  • This is not any (first success) and not all (every success).

First to settle, not first to succeed

The implementation is short on purpose. Short is where people invent extra policy.

function myPromiseRace(iterable) {
  return new Promise((resolve, reject) => {
    for (const item of iterable) {
      Promise.resolve(item).then(resolve, reject);
    }
  });
}

The outer resolve / reject are no-ops after the first call. Later settlements still have handlers, so they are not unhandled rejections.

If iterable is not iterable, the executor throws and the returned promise rejects with that TypeError.

No remaining-count. No result array. No empty-array shortcut. If the for loop body never runs, nobody ever calls resolve or reject. That is the empty-iterable hang.

myPromiseRace([
  new Promise((res) => setTimeout(() => res('slow'), 50)),
  Promise.reject(new Error('fast fail')),
]).catch((err) => err.message); // 'fast fail'

myPromiseRace([Promise.resolve(1), Promise.resolve(2)]); // 1
myPromiseRace([1, Promise.resolve(2)]); // 1, after a microtask
myPromiseRace([]); // pending, forever

Plain 1 still goes through Promise.resolve, so it wins on a microtask, not synchronously. Two already-fulfilled promises: both reactions are queued in iteration order, so the first input wins.

Thenables: { then: (res, rej) => rej(new Error('nope')) } can win as a rejection. If you forget Promise.resolve and the object is not a native promise, you never attach, and a slower native promise “wins” incorrectly.

Timeouts, without the cancellation myth

Racing a timer is a legitimate timeout signal. It is not a stop button.

function withTimeout(promise, ms) {
  let id;
  const timeout = new Promise((_, reject) => {
    id = setTimeout(() => reject(new Error('Timeout')), ms);
  });
  return myPromiseRace([promise, timeout]).finally(() => {
    clearTimeout(id);
  });
}

finally plus clearTimeout stops the timer from firing after the work wins (and from keeping a process or test alive). The work promise is not aborted. A fetch that lost the race still completes unless you pass a signal and abort() in the timeout path. That helper belongs on timeout a promise, not stuffed into this polyfill.

Combinator Winner Empty iterable
race First settle (fulfill or reject) Never settles
any First fulfill Rejects AggregateError
all All fulfill, or first reject Fulfills []
allSettled All settle, always a bag Fulfills []

If the interviewer says “first of these CDNs that works,” they want any. If they say “give me the first answer, error or not,” they want race.

Footguns in a Promise.race polyfill

Empty race hangs. await myPromiseRace([]) deadlocks that task. Tests that Promise.race an empty filtered list (all items dropped) will time out in CI. Guard at the call site if an empty list is possible.

Do not return after attaching the first handler. You must attach to every item; the fastest settlement might be the third input.

Do not convert to an array and sort by a made-up duration. You do not know durations. You subscribe and let the microtask/macrotask queues decide.

A never-settling input plus one that fulfills: the fulfiller wins, the pending one is ignored and still not cancelled. All inputs pending: race hangs, same as empty. That is why a timeout timer is the usual second racer.

race is a bad cancel primitive. The losing promise’s then still runs. Side effects still happen. Use AbortController when the work can listen.

Sync thenables without Promise.resolve can call resolve inside the for loop. Native wrapping avoids that class of “settled before the rest were subscribed” bugs, which is the same reason all wraps. Already-rejected native promises still reject on a microtask; they do not throw from myPromiseRace itself.

Interview answer (out loud)

“Promise.race settles with whichever input settles first, success or failure. I wrap each item with Promise.resolve and attach both resolve and reject of the result promise. An empty iterable leaves that promise pending forever, which is spec, not a bug I ‘fix’ by rejecting. Race does not cancel the other work; a timeout still needs clearTimeout, and real IO cancel needs AbortController. First success only is Promise.any.”

Further reading

Related guides