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.
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:
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 100msCrucially, 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:
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.any — Promise.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:
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:
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:
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:
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:
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
| Behavior | Promise.race | Promise.any |
|---|---|---|
| Reacts to | First settled (fulfill OR reject) | First fulfilled only |
| Rejects when | First rejection wins the race | ALL inputs reject |
| Error when all fail | N/A (first rejection triggers) | AggregateError with all errors |
| Use for | Timeouts, first-result-wins | Redundant fallbacks, retry |
// 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
// 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 immediatelyEvent 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
Key Insights
- Promise.race reacts to any outcome: The first Promise to settle — fulfilled or rejected — determines the race result, unlike
Promise.anywhich 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.raceignores losing results but does not stop the underlying work;AbortControlleris 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.anycorrectly ignores early failures whilePromise.racewould reject on the first failure
Frequently Asked Questions
Does Promise.race cancel the losing Promises?
What happens if I pass an empty array to Promise.race?
Can I use Promise.race to implement a simple debounce?
Is Promise.race the same as a conditional if one Promise resolves before another?
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.
More in this topic
OffscreenCanvas API in JS for UI Performance
Master the OffscreenCanvas API to offload rendering from the main thread. Covers worker-based 2D and WebGL rendering, animation loops inside workers, bitmap transfer, double buffering, chart rendering pipelines, image processing, and performance measurement strategies.
Advanced Web Workers for High Performance JS
Master Web Workers for truly parallel JavaScript execution. Covers dedicated and shared workers, structured cloning, transferable objects, SharedArrayBuffer with Atomics, worker pools, task scheduling, Comlink RPC patterns, module workers, and performance profiling strategies.
JavaScript Macros and Abstract Code Generation
Master JavaScript code generation techniques for compile-time and runtime metaprogramming. Covers AST manipulation, Babel plugin authorship, tagged template literals as macros, code generation pipelines, source-to-source transformation, compile-time evaluation, and safe eval alternatives.