← back to journal

Node lets go of finished requests; a hand-rolled Promise.race timeout doesn't

2026-10-13 · Kit Wickham

Today I found that asyncio and Go both keep a finished request's data alive after its timeout is cancelled, because the cancelled timer stays in the runtime's heap still pointing at it. Node.js 26 doesn't do this. clearTimeout unlinks the timer right away, AbortSignal.timeout's timer only holds a weak reference to its signal, and fetch with a timeout signal kept nothing in my tests. What does keep requests alive in Node is two common userland patterns, and there the retention is by design, not a bug: a Promise.race timeout whose timer is never cleared, and an abort listener on a timeout signal that is never removed. Both keep every request's data for the full timeout. At about 440 requests a second with 30-second timeouts and 32 KiB per request, that was about 13,000 finished requests and 430 MB held at any moment.

The test

Each request makes a 32 KiB state object, does 1–3 ms of async work under a 30-second timeout, and returns. I register every state object in a FinalizationRegistry. After 2,000 requests have finished, with all their timeouts still pending, I force a few garbage collections (node --expose-gc) and count the states that haven't been finalized. Anything above zero is a finished request something still reaches. Node v26.10.0, Linux arm64.

PatternKept of 2,000
setTimeout + clearTimeout when done0
Promise.race with a timeout promise, timer never cleared2,000
Same, clearTimeout in finally0
timers/promises setTimeout with a signal, aborted when done0
AbortSignal.timeout + abort listener, never removed2,000
Same with { once: true }2,000
Same with events.addAbortListener, never disposed2,000
Listener removed (or disposed) when done0
AbortSignal.any([controller, timeout]), listener removed0
events.once(emitter, …, { signal: AbortSignal.timeout(…) })0
fetch with no signal, AbortSignal.timeout, or AbortSignal.any (Response objects kept)0

With a 3-second timeout, the 2,000 kept in the race and listener rows went to 0 a second after the timers fired. Nothing leaks forever; it's held for exactly as long as the timeout.

Why the race keeps the result

This is the hand-rolled timeout I mean:

const result = await Promise.race([
  doWork(),
  new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 30_000)),
]);

When doWork() wins, the race's promise settles with its result, but the timer is still armed. The timer's callback holds reject, which holds the timeout promise. Promise.race subscribed to the timeout promise, so the timeout promise holds a reaction pointing back at the race's promise, and the race's promise holds the result. Every result stays reachable until its timer fires 30 seconds later. The timer also keeps the process from exiting, which is the reason most people know to clear it. Clearing it in a finally fixes both.

Why the listener keeps the signal

AbortSignal.timeout is written so that a pending timer doesn't keep its signal alive: the timer closes over a WeakRef, and a finalization registry clears the timer if the signal is collected. But a signal with an abort listener has to be able to fire it. So when the first non-weak abort listener is added, Node puts the signal in a module-level set, gcPersistentSignals, and takes it out when the listener is removed or the timer fires (lib/internal/abort_controller.js). A listener that closes over the request, and is never removed because the request finished first, keeps the request until the timeout. { once: true } doesn't help: the listener is only removed once it has run.

AbortSignal.any also adds every timeout signal you pass it to that set, listener or not. In my test that cost about 650 bytes per call of Node's own objects until the timer fired (20,000 calls: 17.9 MB of heap against 4.6 MB for the control, back to 5.4 MB after the timeouts). No request data came with it.

Under steady load

I ran each pattern at about 440 requests a second for 60 seconds, then stopped the load, sampling once a second after a forced GC. The race and the { once: true } listener both climbed for 30 seconds and leveled off at about 13,000 finished requests and 430 MB of buffers, which is rate × timeout × 32 KiB. After the load stopped they drained to 0 over the next 30 seconds. The cleared race, the removed listener, AbortSignal.any and fetch stayed at about 25, the requests actually in flight.

What to take from it

Node's runtime keeps none of your request data that it can't still use, which is the difference from asyncio and Go: there, the timer is cancelled and can never fire, yet still holds the request. In Node, what holds the request can still run. The fixes are the ones you'd expect: clear the timer of a race in finally, or use AbortSignal.timeout and remove your abort listeners (or dispose what events.addAbortListener returns) when the work finishes. The cost of not doing it is your request rate × your timeout × your request size, held all the time.