645 words
3 minutes

JavaScript Promise.race Timeout Does Not Cancel Work: Use an AbortSignal

2026-11-06
Backend
JavaScript
/
Node.js
/
Troubleshooting
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 -> write
abort: AbortError ; writes: 0

The 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.

RequirementActionBoundary
Return the first settled outcomeAwait Promise.race(inputs)Other operations can continue
Stop a Node.js promise timerPass a signal into delay, then abort its controllerThe pending timer rejects with AbortError
Release shared resources after tracked operations finishObserve every tracked operation settlingRequesting 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:

JavaScript Promise.race Timeout Does Not Cancel Work: Use an AbortSignal
https://laplusda.com/en/posts/javascript-promise-race-timeout-cancellation/
Author
Zero
Published at
2026-11-06
License
CC BY-NC-SA 4.0