π§ The Promise.race() Winner That Didn’t Actually Stop Anything
Promise.race() settles as soon as the first of several promises settles, and returns that result – but it has no way to reach into the other promises and stop them, because a JavaScript Promise has no built-in cancellation mechanism at all. A promise that ‘loses’ the race keeps running exactly as if it had never been raced against anything, and whatever it eventually does – resolve with a now-unused result, or reject with an error nobody is positioned to catch – still happens, just later and unnoticed.
π The Problem
function withTimeout(promise, ms) {
const timeout = new Promise((_, reject) =>
setTimeout(() => reject(new Error("Timed out")), ms)
);
return Promise.race([promise, timeout]);
}
async function loadProfile(userId) {
const result = await withTimeout(fetchProfile(userId), 2000);
return result;
}
// fetchProfile() keeps running in the background even after the
// 2-second timeout "wins" the race - it's not cancelled, aborted,
// or told to stop in any way. If it eventually resolves, nothing
// is listening anymore. If it eventually REJECTS instead, that
// rejection has no .catch() attached to it by this point, and
// surfaces as an unhandled promise rejection seconds later, far
// from the timeout logic that seemingly already "handled" it.
β Fix: Actually Cancel the Loser, Don’t Just Ignore It
- Pass an AbortController’s signal into the underlying request (fetch and many libraries support this directly) and call controller.abort() specifically when the timeout promise wins the race, so the losing operation is actually told to stop instead of merely being ignored.
- Attach a no-op .catch() to the original (potentially losing) promise regardless of whether it wins the race, specifically to prevent an eventual rejection from surfacing as an unhandled rejection later, even if the result itself is no longer needed.
- For anything with a real cost to leaving it running (an open network connection, a database query, a file read), treat Promise.race() as choosing which result to use, not as a cancellation mechanism – actual cancellation needs to be wired up separately and explicitly.
β οΈ Why This Is Easy to Miss
- The timeout path works exactly as expected in every manual test – the request that lost the race finishing (or failing) in the background a few seconds later doesn’t visibly break anything in the moment, which is why the missing cancellation goes unnoticed for a long time.
- An unhandled rejection from the losing promise can crash a Node.js process outright (depending on configuration) or spam error-tracking tools in a browser, and by the time it surfaces, the stack trace points at generic promise machinery rather than the original timeout logic that was actually responsible.
Promise.race() picks a winner – it never tells the losers the race is over, and they keep running exactly as if they’d won it too.
