A request that takes thirty seconds to answer is not a slow request, it is a lost one. But you still need a way to decide for yourself when to give up.
Promise.race exists for exactly that: put two outcomes against each other and keep whichever shows up first.
Definition
Promise.race(list) returns a promise that takes on the outcome of the first promise in the list to settle, fulfilled or rejected alike. The others keep running, and their result is ignored.
const later = (v, ms) => new Promise((r) => setTimeout(() => r(v), ms));
async function demo() {
const winner = await Promise.race([
later("slow", 300),
later("fast", 50),
]);
console.log(winner); // fast
}
demo();The word race is well chosen: there is only one winner, and a rejected promise that arrives before the others wins just as surely as a fulfilled one.
Setting a timeout
This is the main use. You run the real work against a promise that fails after a given time, and whichever comes first decides.
const failAfter = (ms) => new Promise((_, reject) => {
setTimeout(() => reject(new Error("Timed out")), ms);
});
async function withTimeout(work, ms) {
return Promise.race([work, failAfter(ms)]);
}
withTimeout(later("answer", 400), 100)
.then((v) => console.log(v))
.catch((err) => console.log(err.message)); // Timed outThe caller gets a clear error instead of an endless wait, and the catch treats the timeout exactly like any other failure.
race and any: two different races
| Method | What triggers the result | When failures pile up |
|---|---|---|
Promise.race | The first to settle, success or failure | The first failure wins |
Promise.any | Only the first to fulfill | An AggregateError once all are rejected |
Those three columns separate two needs. race answers the question of elapsed time, any answers the question of which server among several mirrors replies correctly first.
Promise.race([]) returns a promise that never settles, since no competitor can possibly win. An await placed on it freezes the rest of the function without raising a single error, so check that the list is not empty before passing it in.
Frequently asked questions
Does the loser get canceled?
No, and this is the costliest misunderstanding of the lot. The discarded promise carries its work through to the end: the request still leaves, the server still answers, the bill is the same. To really interrupt the call you need an AbortController.
What becomes of the loser's rejection?
It happens after the fact, with nobody listening, and the engine reports it as an unhandled rejection. An empty catch attached to the discarded promise is enough to neutralize it, or better still, a clean cancellation that stops it from happening at all.
How do you retry instead of giving up?
By wrapping the call in a loop that starts over after a failure, with a growing pause between attempts. The race sets the limit of a single attempt, the loop decides how many attempts there are: the two mechanisms combine and should never be confused.