Promise.race settles with the first promise to fulfill or reject, which makes it a natural way to enforce a deadline: race the real operation against a timer that rejects. The catch is that race does not cancel the losing promise, so you must clear the timer and abort the underlying work yourself.
Before you start
You should be comfortable with promises, Promise.race, and AbortController. This article builds a timeout wrapper; it does not replace a retry or backoff policy.
Step-by-step walkthrough
Step 1: Build a rejecting timer
Create a promise whose executor schedules setTimeout and rejects with a timeout error. Keeping the timer id in the enclosing scope lets you clear it after the race, which would otherwise leave a dangling timer and keep the process alive.
Step 2: Race and always clean up
Race the operation against the timer, then clear the timer in .finally. finally runs whether the operation resolves or rejects, so the timer is removed on both paths. Without it, a fast success still leaves a pending timer.
Step 3: Cancel the loser, do not just ignore it
When the timeout wins, the original promise keeps running and may later reject. Attach a .catch(() => {}) to it, or pass an AbortSignal so the transport stops. Otherwise a late rejection can surface as an unhandled rejection even though you already handled the timeout.
Worked scenario
Run this with Node.js 18 or later. The timer is cleared on success.
function withTimeout(promise, ms) {
let timer;
const timeout = new Promise((_, reject) => {
timer = setTimeout(() => reject(new Error('timed out')), ms);
});
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
}
const value = await withTimeout(Promise.resolve('ok'), 1000);
console.log(value); // okWalk through the example
The operation resolves immediately, so it wins the race and withTimeout fulfills with 'ok'. The finally still runs and clears the timer, so the event loop is not kept alive for a second. If the operation had taken longer than one second, the timer would have rejected first and the returned promise would reject with Error: timed out.
Common mistake
Leaving the timer uncleared, which delays process exit in Node.js tests, and assuming the race cancels the operation. In browsers, the original fetch continues consuming a connection. Combine the timeout with AbortController so the deadline actually stops the request.
Verify the behavior
Assert that a fast operation resolves and that the process exits promptly, proving the timer was cleared. Assert that a slow operation rejects with the timeout error. Then confirm the abandoned promise does not produce an unhandled rejection by attaching a handler, and check that the abort signal fired for the cancelled request.
Interview exercise
Does Promise.race([fetch(url), timeout]) cancel the fetch when the timeout wins?
Answer and reasoning
No. race only reports the first settled promise; it has no mechanism to cancel the other one, so the fetch keeps going and its later result or error is discarded. To stop the work, pass an AbortController signal to fetch and abort it in the timeout path, and keep a .catch on the fetch so a late rejection is handled. The race enforces the deadline; the controller enforces cancellation.
Continue learning
Compare the combinators in JavaScript promise combinators and review request cancellation in AbortSignal composition. Read the MDN Promise.race reference and try the JavaScript interview questions.