InterviewsVector
beginnerCommon4 min read · Updated Jul 18, 2026

Implement Promise.race from Scratch

Implement Promise.race in JavaScript, use it for the timeout pattern with cleanup, and know why an empty race pends forever instead of resolving.


The problem

Implement promiseRace(iterable): settle exactly like the first input promise that settles — fulfillment or rejection, whichever happens first.

The implementation is the shortest of the four combinators — settle-once does all the work. Which is precisely why interviewers ask it: the code takes two minutes, and the remaining twenty are about semantics (first-settle vs first-success), the empty-input quirk, and the timeout pattern built on top.

Implementation

function promiseRace(iterable) {
  return new Promise((resolve, reject) => {
    for (const item of iterable) {
      // Every contender gets both callbacks; the first to call
      // either one wins, because a promise can only settle once.
      Promise.resolve(item).then(resolve, reject);
    }
  });
}

That's the entire mechanism: N subscriptions racing to call resolve/reject on one promise, and settle-once discards all but the first. If you've internalized how promises settle, there is genuinely nothing else.

The empty-race quirk — do not "fix" it

Promise.race([]); // pends FOREVER — by spec

With no contenders, nothing can ever settle. Many hand-rolled versions "gracefully" resolve undefined for empty input — that's not defensive coding, it's a spec violation that turns a detectable hang into silent wrong data. The empty-input row is where all four combinators differ, and it's a favorite rapid-fire question:

all([])allSettled([])any([])race([])
Resultresolves []resolves []rejects (AggregateError)pends forever

The pattern this question is really about: timeouts

function withTimeout(promise, ms) {
  let timerId;
  const timeout = new Promise((_, reject) => {
    timerId = setTimeout(
      () => reject(new Error(`Timed out after ${ms}ms`)),
      ms
    );
  });
 
  return Promise.race([promise, timeout]).finally(() => {
    clearTimeout(timerId); // don't leave a live timer after winning
  });
}
 
const user = await withTimeout(fetchUser(), 3000);

Two production details separate a senior answer:

  1. Clear the timer. If the real promise wins, an uncleared timeout keeps the event loop alive (Node) or fires a pointless rejection later. The finally is not decoration.
  2. Timeout ≠ cancellation. Losing the race doesn't stop the underlying fetch — it keeps running and its result is discarded. Actually stopping it needs AbortController, and AbortSignal.timeout(ms) is now the built-in that fuses both halves of this pattern.

Edge cases interviewers probe

  • A rejection can winrace([slowSuccess, fastFailure]) rejects. If you want failures to lose, that's Promise.any — first-settle vs first-success is the distinction this question exists to check.
  • Already-settled contenderrace([Promise.resolve(1), anything]) fulfills with 1 on the next microtask; "immediately" still means asynchronously (see the event loop).
  • Plain valuesrace([42, slowPromise])42; Promise.resolve wrapping again.
  • Losers keep runningrace abandons results, it doesn't cancel work. Repeat it until it sticks; it's the most common wrong belief about this API.
  • Iterables — spec accepts any iterable; hence for...of rather than .forEach on an assumed array.

Common mistakes

  • Resolving empty input with undefined (the "helpful" spec violation above).
  • Treating race as "first success" and being surprised by winning rejections.
  • The timeout pattern without clearTimeout — a real bug in Node services (lingering timers delay process exit and pile up under load).
  • Rebuilding race with flags (let settled = false; if (!settled) {...}) — redundant state that settle-once already provides; interviewers read it as not trusting the primitive.
  • Believing the losing fetch was cancelled.

Test yourself

Predict the outputWhat does this code print?
Promise.race([
new Promise((res) => setTimeout(res, 200, "slow win")),
new Promise((_, rej) => setTimeout(rej, 100, "fast fail")),
]).then((v) => console.log("then:", v))
.catch((e) => console.log("catch:", e));

Follow-up questions

  • "Implement withTimeout without leaking the timer." — the pattern above; the question is specifically fishing for clearTimeout.
  • "Race N sources but tolerate failures?"Promise.any; compare semantics precisely.
  • "How would you cancel the losers?" — hedged requests: give every contender an AbortSignal, abort the rest when a winner settles — see AbortController.
  • "What's AbortSignal.timeout?" — the modern built-in for deadline + cancellation in one: fetch(url, { signal: AbortSignal.timeout(3000) }); knowing it exists dates your knowledge post-2022, which is the point of the question.
  • "Build race from scratch-promise primitives." — it's the four-line function above sitting on settle-once semantics; explaining why no extra state is needed is the actual answer.

  • Promise.any

    intermediate

    Implement Promise.any with AggregateError semantics: first success wins, all failures reject.

  • Run N async tasks with at most K in flight — the production-style p-limit pattern, including ordering and failure trade-offs.

  • Promise.allSettled

    intermediate

    Implement allSettled: wait for every promise and report per-promise status objects.