JavaScript Promise.race Timeout Does Not Cancel Work: Use an AbortSignal
Contents
A Promise.race() timeout stops your caller from waiting for a result. It does not stop the operation represented by the losing promise. If that operation writes to a file, updates state, or holds a resource, it can continue after your timeout handler runs.
Use cancellation supported by the underlying API when the work must stop. Passing a promise to a race adds no cancellation capability to that operation.
Prove that the losing operation still runs
Save this complete example as race-timeout.mjs and run node race-timeout.mjs. It was executed during article preparation on macOS with Node.js 24.13.0, on November 6, 2026. The first case uses a rejected promise as a controlled deadline signal: no wall-clock timing assumption decides which operation wins. The second case cancels a real Node.js promise timer.
import assert from 'node:assert/strict';import { setTimeout as delay } from 'node:timers/promises';
const events = [];let release;const gate = new Promise(resolve => { release = resolve; });const work = gate.then(() => { events.push('write'); return 'done'; });try { await Promise.race([work, Promise.reject(new Error('deadline'))]);} catch (error) { events.push(error.message);}assert.deepEqual(events, ['deadline']);release();await work;assert.deepEqual(events, ['deadline', 'write']);console.log('race:', events.join(' -> '));
const controller = new AbortController();const canceled = [];const pending = delay(60_000, undefined, { signal: controller.signal }) .then(() => canceled.push('write'));const drained = Promise.allSettled([pending]);controller.abort();const [result] = await drained;assert.equal(result.status, 'rejected');assert.equal(result.reason.name, 'AbortError');assert.deepEqual(canceled, []);console.log('abort:', result.reason.name, '; writes:', canceled.length);The assertions pass and print:
race: deadline -> writeabort: AbortError ; writes: 0The first assertion runs before the gate opens. Only deadline exists then; the write happens after the caller has caught the rejection. Replacing the immediate rejection with a timer that rejects before the gate opens changes when the deadline arrives, not whether the gated work can continue.
The second case passes a signal into the operation itself. Aborting that signal rejects the pending timer, so its fulfillment handler never records a write. allSettled() observes the rejection before the script finishes; cancellation and observing completion are separate steps.
Choose between abandoning a result and stopping work
The ECMAScript race algorithm connects each input to the returned promise’s resolve and reject functions. It contains no instruction to cancel the losing operations. This explains the first trace without treating a timeout as a special promise state.
| Requirement | Action | Boundary |
|---|---|---|
| Return the first settled outcome | Await Promise.race(inputs) | Other operations can continue |
| Stop a Node.js promise timer | Pass a signal into delay, then abort its controller | The pending timer rejects with AbortError |
| Release shared resources after tracked operations finish | Observe every tracked operation settling | Requesting cancellation alone does not prove completion |
Node’s timer documentation specifies cancellation through an AbortController for promise timers. The test covers that API, not arbitrary promises. A library that ignores the signal will keep running; a synchronous loop cannot be interrupted merely by attaching a signal to an unrelated object.
For the separate problem of an aggregate rejection triggering cleanup while sibling work continues, see Promise.all cleanup ordering. Here the central issue is the timeout’s authority over the operation, rather than collecting a group of results.
Apply the boundary to fetch timeouts
For fetch, a supported timeout signal can be passed directly as the request’s signal, using AbortSignal.timeout(milliseconds). MDN documents that expiration aborts this signal with a TimeoutError. This is an API-specific cancellation route; racing fetch against an unrelated rejecting timer does not connect that timer to the request.
This article’s executed fixture does not test fetch, browser suspension, response-body streaming, or remote server behavior. In particular, client cancellation does not establish that a server rolled back work it already accepted. Check the affected request and server contract before retrying an operation with side effects.
If you implement a deadline with your own setTimeout, keep its handle and call clearTimeout when the operation finishes before that deadline. Settling a race does not clear a losing timer either. Choose one owner for the timer, the cancellation request, and the observation of task completion; keep resource cleanup after the boundary your application actually requires.
References: