How to Use Promise.race in JavaScript: Complete Guide

Master Promise.race in JavaScript. Learn how it works, when to use it, how to build robust timeout wrappers, cancel slow requests with AbortController, and understand the difference from Promise.any.

JavaScriptintermediate
11 min read

Promise.race settles with the outcome of whichever input Promise settles first — whether that is a fulfillment or a rejection. This racing behavior enables a class of patterns that no other concurrency method supports: timeouts, fastest-response routing, and cancellable operations. This guide covers how it works, its common patterns, its limitations, and when Promise.any is a better choice.

How Promise.race Works

Promise.race(iterable) returns a Promise that adopts the state of the first input Promise to settle:

javascriptjavascript
const winner = await Promise.race([
  new Promise((resolve) => setTimeout(() => resolve("slow"), 500)),
  new Promise((resolve) => setTimeout(() => resolve("fast"), 100)),
  new Promise((resolve) => setTimeout(() => resolve("medium"), 300)),
]);
 
console.log(winner); // "fast" — settled at 100ms

Crucially, the other Promises continue running. Promise.race does not cancel them — it just stops waiting for them. Their eventual settlements are silently ignored.

Race With Rejection

If the first Promise to settle is rejected, Promise.race rejects:

javascriptjavascript
const result = await Promise.race([
  new Promise((_, reject) => setTimeout(() => reject(new Error("fast fail")), 100)),
  new Promise((resolve) => setTimeout(() => resolve("slow success"), 500)),
]).catch((err) => `Caught: ${err.message}`);
 
console.log(result); // "Caught: fast fail"

This is the key difference from Promise.anyPromise.race does not distinguish between fulfillments and rejections.

Pattern 1: Timeout Wrapper

The classic use case for Promise.race is enforcing a maximum wait time on any async operation:

javascriptjavascript
function withTimeout(promise, ms, label = "Operation") {
  const timeout = new Promise((_, reject) =>
    setTimeout(
      () => reject(new Error(`${label} timed out after ${ms}ms`)),
      ms
    )
  );
  return Promise.race([promise, timeout]);
}
 
// Usage
async function loadUserWithTimeout(userId) {
  try {
    const user = await withTimeout(fetchUser(userId), 3000, "fetchUser");
    return user;
  } catch (err) {
    if (err.message.includes("timed out")) {
      console.warn(err.message);
      return cachedUser(userId) || null;
    }
    throw err; // Non-timeout error: re-throw
  }
}

Identifying Timeout Errors

Using a custom error class helps distinguish timeout failures from other rejections:

javascriptjavascript
class TimeoutError extends Error {
  constructor(ms, label) {
    super(`${label} timed out after ${ms}ms`);
    this.name = "TimeoutError";
    this.ms = ms;
  }
}
 
function withTimeout(promise, ms, label = "Operation") {
  let timer;
  const timeout = new Promise((_, reject) => {
    timer = setTimeout(() => reject(new TimeoutError(ms, label)), ms);
  });
 
  // Clean up the timer if the main promise wins
  return Promise.race([
    promise.finally(() => clearTimeout(timer)),
    timeout,
  ]);
}
 
// Check by type:
try {
  const data = await withTimeout(fetchData(url), 5000, "fetchData");
} catch (err) {
  if (err instanceof TimeoutError) {
    showStaleData();
  } else {
    showError(err);
  }
}

Notice the .finally(() => clearTimeout(timer)) — this prevents a dangling timeout callback if the main Promise wins, avoiding a potential memory leak.

Pattern 2: Fastest Available Server

Race a request against multiple endpoints and use whichever responds first:

javascriptjavascript
async function fetchFromFastest(path) {
  const endpoints = [
    "https://us.api.example.com",
    "https://eu.api.example.com",
    "https://ap.api.example.com",
  ];
 
  return Promise.race(
    endpoints.map((base) =>
      fetch(`${base}${path}`).then((res) => {
        if (!res.ok) throw new Error(`${base}: HTTP ${res.status}`);
        return res.json();
      })
    )
  );
}

Limitation: If the fastest server returns an error response, Promise.race will reject with that error even if the other, slower servers would have succeeded. For "first success wins" semantics, use Promise.any instead.

Pattern 3: Cancellation With AbortController

Since Promise.race does not cancel the "losing" requests, pair it with AbortController to actually stop the network requests:

javascriptjavascript
async function fetchWithTimeoutAndCancel(url, ms) {
  const controller = new AbortController();
  const { signal } = controller;
 
  const fetchPromise = fetch(url, { signal }).then((r) => r.json());
 
  const timeoutPromise = new Promise((_, reject) =>
    setTimeout(() => {
      controller.abort(); // Cancel the fetch
      reject(new TimeoutError(ms, url));
    }, ms)
  );
 
  try {
    return await Promise.race([fetchPromise, timeoutPromise]);
  } catch (err) {
    if (err.name === "AbortError") {
      throw new TimeoutError(ms, url); // Normalize the abort error
    }
    throw err;
  }
}

Pattern 4: Loading State With Delay

Show a loading spinner only if an operation takes longer than a threshold — avoiding a flash of the spinner for fast operations:

javascriptjavascript
async function loadWithDelayedSpinner(fetchFn, spinnerDelay = 200) {
  let spinnerTimeout;
  let spinnerShown = false;
 
  const spinnerPromise = new Promise((resolve) => {
    spinnerTimeout = setTimeout(() => {
      showSpinner();
      spinnerShown = true;
      resolve();
    }, spinnerDelay);
  });
 
  try {
    // Race the fetch with the spinner timer
    const result = await Promise.race([
      fetchFn().then((data) => ({ type: "data", data })),
      spinnerPromise.then(() => ({ type: "spinner" })),
    ]);
 
    if (result.type === "spinner") {
      // Spinner shown, wait for the actual data
      const data = await fetchFn(); // Not ideal — see note below
      return data;
    }
 
    clearTimeout(spinnerTimeout);
    return result.data;
  } finally {
    if (spinnerShown) hideSpinner();
  }
}
 
// Better approach: run fetch independently, race just determines spinner
async function loadWithDelayedSpinnerV2(fetchFn, spinnerDelay = 200) {
  const dataPromise = fetchFn();
  const spinnerTimer = setTimeout(() => showSpinner(), spinnerDelay);
 
  try {
    const data = await dataPromise;
    return data;
  } finally {
    clearTimeout(spinnerTimer);
    hideSpinner();
  }
}

Promise.race vs Promise.any

BehaviorPromise.racePromise.any
Reacts toFirst settled (fulfill OR reject)First fulfilled only
Rejects whenFirst rejection wins the raceALL inputs reject
Error when all failN/A (first rejection triggers)AggregateError with all errors
Use forTimeouts, first-result-winsRedundant fallbacks, retry
javascriptjavascript
// race: a rejection at 100ms beats a fulfillment at 200ms
const r = await Promise.race([
  delay(100).then(() => Promise.reject("fast error")),
  delay(200).then(() => "slow success"),
]).catch((e) => e);
console.log(r); // "fast error"
 
// any: skips the rejection, waits for first success
const a = await Promise.any([
  delay(100).then(() => Promise.reject("fast error")),
  delay(200).then(() => "slow success"),
]);
console.log(a); // "slow success"

Edge Cases

javascriptjavascript
// Empty iterable: pending forever
Promise.race([]); // Never settles — avoid this
 
// Already-settled Promises: settles synchronously (in next microtask)
const p = await Promise.race([Promise.resolve(1), Promise.resolve(2)]);
console.log(p); // 1 — order determines winner for already-resolved
 
// Non-Promise values: treated as already-resolved
const v = await Promise.race([42, fetchData()]);
console.log(v); // 42 — plain value wins immediately

Event Loop Interaction

Promise.race registers .then() handlers on all input Promises. When the first settles, its microtask schedules the resolution of the race Promise. From the event loop's perspective, Promise.race has no special status — it is just a Promise that resolves when it receives the first fulfillment/rejection notification from its internal handlers.

Rune AI

Rune AI

Key Insights

  • Promise.race reacts to any outcome: The first Promise to settle — fulfilled or rejected — determines the race result, unlike Promise.any which waits for a fulfillment
  • Always clean up losing timers: Use .finally(() => clearTimeout(timer)) on the winner to prevent dangling callbacks and memory leaks
  • Pair with AbortController for real cancellation: Promise.race ignores losing results but does not stop the underlying work; AbortController is needed to actually cancel fetch requests
  • Empty array never settles: Promise.race([]) returns a permanently pending Promise — guard against empty inputs
  • Use Promise.any for fallback patterns: When the goal is "succeed on at least one source," Promise.any correctly ignores early failures while Promise.race would reject on the first failure
RunePowered by Rune AI

Frequently Asked Questions

Does Promise.race cancel the losing Promises?

No. `Promise.race` only ignores the results of the losing Promises; it does not and cannot cancel them. The underlying async operations (network requests, timers) continue until they complete naturally. Use `AbortController` for fetch requests or a cancellation token pattern for other operations if you need to actually stop the work.

What happens if I pass an empty array to Promise.race?

It returns a Promise that stays permanently pending and never resolves. Always ensure the iterable has at least one Promise. If it might be empty, guard with `if (promises.length === 0) throw new Error("No promises provided")`.

Can I use Promise.race to implement a simple debounce?

Not directly. Debounce cancels earlier calls in favor of later ones, which requires managing timers explicitly. However, `Promise.race` with a rejection timeout can enforce a deadline, which is a related but different concept than debounce.

Is Promise.race the same as a conditional if one Promise resolves before another?

Semantically, yes — it branches based on which settles first. But unlike conditional logic, both operations start simultaneously. `Promise.race` is about temporal concurrency, not conditional branching.

Conclusion

Promise.race is a powerful tool for exactly one class of problems: reacting to the first settled Promise. The timeout wrapper is its canonical use case, and pairing it with AbortController adds real cancellation to the pattern. When you need "first success" rather than "first settled," Promise.any is the better choice. Understanding how rejection handling works in chains and race conditions is essential for using all of these methods reliably.