InterviewsVector
beginnerRare4 min read · Updated Jul 18, 2026

Run N Async Tasks in a Race

Race N async task functions in JavaScript so the first settled result wins — first-settle vs first-success, losers keep running, and the timeout use case.


The problem

Given an array of task functions, start them all and settle with the first task that settles — success or failure, whichever comes first.

The third member of the concurrency-shape family (series = one at a time, parallel = all together and wait for all, race = all together and take the first). The implementation is a one-liner over Promise.race; the interview content is the semantics around it.

Implementation

function runInRace(tasks) {
  if (tasks.length === 0) {
    return Promise.reject(new Error("runInRace: no tasks provided"));
  }
  return Promise.race(tasks.map((task) => task()));
}

Two deliberate decisions to narrate:

  • map((task) => task()) starts everything — same as parallel; race just aggregates differently. Tasks are functions so you control the start (a promise is already running — the rule of this whole family).
  • The empty-array policy is a choice. Raw Promise.race([]) pends forever — correct for the spec method, but for a utility, an eternal hang is usually a worse contract than a loud error, so we reject explicitly. Saying "I'm deviating from spec semantics on purpose, here's why" earns more credit than either behavior does silently. (Full quirk table in the Promise.race article.)

Dry run

const tasks = [
  () => delay(1000, "slow"),
  () => delay(500, "medium"),
  () => delay(300, "fast"),
];
 
await runInRace(tasks); // "fast" after ~300ms
// t=0:   all three timers start
// t=300: "fast" settles → race settles → DONE from the caller's view
// t=500, t=1000: losers still complete — results discarded, work not stopped

First-settle vs first-success — the distinction that decides correctness

A rejection can win the race. If the fastest task fails at 100ms, runInRace rejects at 100ms even though a success was coming at 300ms:

runInRace([
  () => delay(100).then(() => { throw new Error("fast failure"); }),
  () => delay(300, "slow success"),
]).catch((e) => e.message); // "fast failure" — the success never got its chance

If failures should lose rather than win — querying redundant mirrors, cache-vs-network — the tool is Promise.any (first fulfillment, aggregate rejection only if all fail). Choosing between race and any based on the failure story is precisely what this question screens for.

The real production use: timeouts and fallbacks

Racing N equivalent sources is rare; racing work against a deadline is everywhere:

const result = await runInRace([
  () => fetchFromApi(query),
  () => delay(3000).then(() => { throw new Error("Timed out after 3s"); }),
]);

Two caveats that upgrade the answer: clear the timer when the real task wins (don't leak it), and remember losing the race doesn't cancel the fetch — the request completes and gets discarded. Deadline plus cancellation is AbortSignal.timeout(ms) / AbortController, and hedged-request systems abort the losers explicitly on first win.

Edge cases interviewers probe

  • Rejection wins (above) — the core semantic check.
  • Losers keep running — race abandons results; side-effectful losers (writes!) still happen. Racing non-idempotent operations is a design smell worth calling out.
  • Already-settled task — wins on the next microtask; "immediately" is still async (event loop).
  • Sync throw in a tasktask() throws during map, before race exists; wrap with Promise.resolve().then(task) if tasks are untrusted.
  • One task, or non-promise return values — degenerate cases that should just work; race wraps values via Promise.resolve.

Common mistakes

  • Confusing race with "first success" and shipping a fallback chain where a fast failure kills the whole thing.
  • Passing already-running promises and claiming the utility "starts a race."
  • Timeout pattern without clearTimeout — lingering timers keep Node processes alive and pile up under load.
  • Believing the losing request was cancelled.
  • Silently choosing a behavior for [] without stating the contract.

Follow-up questions

  • "Failures should lose — what changes?"Promise.any; be precise about AggregateError on total failure.
  • "Return the first K results instead of the first 1?" — race generalizes to a settle-counter: resolve when K results have landed — a nice on-the-spot design exercise (and k = n gives you parallel back).
  • "Cancel the losers." — one AbortController shared by all tasks, abort() in a finally on the winner — the hedged-request pattern.
  • "Race with a per-task timeout vs a global timeout — difference?" — per-task wraps each task in its own race; global races the whole set against one timer. Different failure semantics; draw them.
  • "Where is this in real infra?" — DNS resolvers querying multiple servers, CDN failover, speculative/hedged RPCs (send to two replicas, take the faster) — the systems vocabulary that turns a utility question into a staff conversation.

  • Convert error-first callback APIs into promise-returning functions, like Node's util.promisify.

  • Fire all tasks at once and gather results — plus what parallel really means on a single thread.

  • Memoize API Calls

    intermediate

    Cache in-flight and resolved requests to deduplicate API calls — with cache invalidation trade-offs.

  • Run async tasks one after another and understand why reduce-with-promises works.