Promise.race() Doesn't Cancel Your Fetch Request: Here's Why
Promise.race() Doesn't Cancel Your Fetch Request: Here's Why Have you ever implemented a timeout for fetch() using Promise.race() and assumed the request would stop when the timeout expired? There is a subtle problem
Promise.race() Doesn't Cancel Your Fetch Request: Here's Why
Have you ever implemented a timeout for fetch() using Promise.race() and assumed the request would stop when the timeout expired?
There is a subtle problem with that approach: Promise.race() settles when the first Promise settles, but it does not cancel the other Promises.
Let's look at why this matters and how to handle request timeouts correctly.
1. The Common Approach: Promise.race()
You might write something like this:
function fetchWithTimeout(url, timeout = 5000) {
const timeoutPromise = new Promise((_, reject) => {
setTimeout(() => {
reject(new Error("Request timed out"));
}, timeout);
});
return Promise.race([
fetch(url),
timeoutPromise
]);
}
At first glance, this seems reasonable.
If the request takes longer than five seconds, the timeout Promise rejects, and Promise.race() rejects as well.
But what happens to the original fetch() request?
It keeps running.
Promise.race() only determines which Promise settles the returned Promise first. It doesn't stop the losing operation.
That means the browser may continue waiting for the response and consuming network resources even though your application has already reported a timeout.
There's another issue: the timer isn't cleared when the request finishes early.
2. A Better Solution: AbortSignal.timeout()
Modern JavaScript provides a built-in way to abort supported operations after a specified duration.
async function fetchWithTimeout(url, timeout = 5000) {
const response = await fetch(url, {
signal: AbortSignal.timeout(timeout)
});
if (!response.ok) {
throw new Error(`HTTP error: ${response.status}`);
}
return response.json();
}
Now, if the timeout expires before the fetch operation completes, the signal is aborted and the fetch rejects.
No custom timeout Promise is needed.
You can also handle the timeout explicitly:
try {
const data = await fetchWithTimeout(
"/api/users",
5000
);
console.log(data);
} catch (error) {
if (error.name === "TimeoutError") {
console.error("The request timed out.");
} else {
console.error("The request failed:", error);
}
}
Note that network errors and HTTP errors are different. A fetch() call doesn't reject just because the server returns a 404 or 500; that's why checking response.ok matters.
3. What If You Need Manual Cancellation Too?
Sometimes a request should stop either when the timeout expires or when the user cancels the operation.
For example, imagine a search interface where users can cancel a request or start another search before the previous one finishes.
You can combine multiple abort signals with AbortSignal.any().
const controller = new AbortController();
const signal = AbortSignal.any([
controller.signal,
AbortSignal.timeout(5000)
]);
try {
const response = await fetch("/api/search?q=javascript", {
signal
});
if (!response.ok) {
throw new Error(`HTTP error: ${response.status}`);
}
const results = await response.json();
console.log(results);
} catch (error) {
if (error.name === "TimeoutError") {
console.log("Request timed out.");
} else if (error.name === "AbortError") {
console.log("Request was cancelled.");
} else {
console.error(error);
}
}
// Cancel manually whenever necessary:
controller.abort();
The combined signal aborts when either the manual controller is aborted or the timeout signal fires.
4. An Important Backend Consideration
There is one more detail that is easy to overlook.
Aborting a fetch request in the browser does not guarantee that the server stops processing it.
The server might already have received the request and started executing a database query or another operation.
This matters even more for operations such as:
- Creating an order
- Processing a payment
- Updating account information
- Triggering a background job
If a client times out and retries, the original operation might still complete. The retry could cause the same action to happen twice.
For operations that must not be duplicated, an idempotency mechanism can help ensure that repeated requests with the same key don't create duplicate effects. The server must implement and enforce that behavior.
Client-side cancellation and server-side cancellation are separate concerns.
5. When Should You Use Each Approach?
Use Promise.race() when you need to race multiple Promises and only care about which settles first, while understanding that losing operations continue unless separately cancelled.
Use AbortSignal.timeout() when you want a supported operation, such as fetch(), to be aborted after a deadline.
Use AbortSignal.any() when you need to combine timeout cancellation with manual cancellation or other abort signals.
Choose based on the behavior you actually need, not just the shortest code.
Final Takeaway
A timeout isn't automatically cancellation.
Promise.race() controls which result your code observes first. AbortSignal gives you a mechanism to signal cancellation to APIs that support it.
Understanding that distinction helps you write more reliable frontend applications and avoid subtle bugs when requests are slow, users retry actions, or the network behaves unpredictably.
Have you ever used Promise.race() to implement a timeout? Did you know the original request could keep running? Share your experience in the comments.
References
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.