Racing Promises and Aborting Fetch Requests in JavaScript
Sometimes, you don’t actually care about all your asynchronous tasks finishing.
You just care about the first one that does something useful.
That’s where Promise.race
comes in.
What is Promise.race
?
If you give Promise.race
an array of promises, it resolves (or rejects) as soon as one of them settles.
It’s like saying:
I don’t want to wait for everyone to finish their homework. The first one who shows me anything wins.
For example:
const fast = new Promise((resolve) => setTimeout(() => resolve("fast!"), 100));
const slow = new Promise((resolve) =>
setTimeout(() => resolve("slow..."), 200),
);
Promise.race([fast, slow]).then(console.log);
// → "fast!"
The slow promise is still running in the background, but we’ve already moved on.
Why should you care?
Because in the real world, waiting can be painful.
Imagine a fetch request that takes too long. Do you just sit there, staring at a loading spinner for eternity? Or do you decide:
If this API doesn’t respond in time, I’ll just abort it and show a fallback.
This is especially relevant for long-running tasks—like calling an AI model that may or may not take 20 seconds.
This is where Promise.race
+ AbortController
come to the rescue.
Racing a fetch with a timeout
Here’s a neat trick:
const abortController = new AbortController();
const backupPromise = new Promise((resolve) =>
setTimeout(() => {
abortController.abort();
resolve("API Request Timeout");
}, 1000),
);
const fetchPromise = fetch("https://jsonplaceholder.typicode.com/todos/1", {
signal: abortController.signal,
}).then((res) => res.json());
const resolvedPromise = await Promise.race([backupPromise, fetchPromise]);
console.log(resolvedPromise);
What happens here?
-
We create an
AbortController
so we can cancel our fetch. -
We make a
backupPromise
that:- waits 1 second,
- aborts the fetch,
- and then resolves with
"API Request Timeout"
.
-
We kick off a
fetchPromise
with the abort signal. -
We race them. Whichever finishes first decides the outcome.
The result
-
If the fetch is fast enough: → you’ll get the JSON response.
-
If the fetch is too slow: → the
backupPromise
wins, aborts the fetch, and you’ll get"API Request Timeout"
.
Either way, the user isn’t stuck waiting forever.
⚠️ A gotcha: promises don’t disappear
Here’s the catch:
Promise.race
doesn’t magically cancel the other promises.
In the earlier fast
vs slow
example, the slow one still runs.
It’s just that nobody cares anymore.
That’s fine for a setTimeout
.
But for things like fetch
requests, this means wasted network calls—unless you explicitly cancel them.
That’s exactly why we used AbortController
in the example.
Without it, the fetch would still finish eventually, burning bandwidth for no reason.
So remember:
Promise.race
picks a winner.- The losers keep going—unless you stop them yourself.
Why this matters
This pattern isn’t just about making your code look fancy. It’s about user experience:
- No endless loading spinners.
- You can fail fast and gracefully recover.
- You can even fallback to a cached result, a loading skeleton, or some default data.
In apps that depend on unpredictable external APIs (or AI services), this kind of control can be the difference between “ugh this app feels broken” and “wow, that was smooth.”
Further reading
At the end of the day, Promise.race
is a little like real life:
you can wait for everyone to finish, or you can let the fastest one decide what happens next.
Sometimes, speed is the better experience.
~Maximilian Walterskirchen