Chapter 4
AbortSignal.any(), FinalizationRegistry, and the WeakCell Leak
How a one-word change in V8’s source code caused AbortSignal.any() to leak memory in Node.js 26.
This is the story of a bug that looked like a Node.js regression but turned out to be a V8 garbage collection change. It took bisecting across Node commits, rebuilding with different V8 versions, analyzing heap snapshots, and finally patching V8’s Torque source to prove the root cause.
The bug
The report was straightforward. This code leaks memory on Node 24+ but works fine on Node 22:
const ac = new AbortController();
let i = 0;
function run() {
AbortSignal.any([ac.signal]);
if (++i % 100_000 === 0) {
const mem = process.memoryUsage().rss / 1024 / 1024;
console.log(`${i} - ${mem.toFixed(2)} MiB`);
}
setImmediate(run);
}
run();
On Node 22 (V8 12.4): memory stable at ~97 MiB. On Node 26 (V8 14.3): memory grows linearly past 2 GB until the process crashes.
How AbortSignal.any() works internally
When you call AbortSignal.any([signal1, signal2]), Node creates a new “composite” signal that aborts when any of the source signals abort. Internally, this involves:
- Create a new
AbortSignal(the composite result) - For each source signal, add a
WeakRefto the result inkDependantSignals - Register with a
FinalizationRegistryso that when the composite signal is GC’d, the deadWeakRefgets cleaned up from the source’skDependantSignals
Step 3 is where the problem lives. Each call to AbortSignal.any() calls FinalizationRegistry.register(), which creates a V8-internal WeakCell object.

What a WeakCell is
FinalizationRegistry is a JavaScript API that lets you register a callback to run when an object is garbage collected. Under the hood, V8 tracks this with WeakCell objects - internal structures that hold a weak reference to the target, the held value, and links into the registry’s internal lists.
When you call registry.register(target, heldValue), V8 creates a WeakCell and adds it to the registry. When target is GC’d, V8 moves the WeakCell to the “cleared cells” list and schedules the cleanup callback.
The key question is: where does V8 allocate this WeakCell?
The V8 change
In V8 12.4, the WeakCell allocation in src/builtins/finalization-registry.tq looked like this:
// Allocate the WeakCell object in the old space, because 1) WeakCell weakness
// handling is only implemented in the old space 2) they're supposedly
// long-living. TODO(marja, gsathya): Support WeakCells in Scavenger.
const cell = new (Pretenured) WeakCell{
map: GetWeakCellMap(),
finalization_registry: finalizationRegistry,
target: target,
holdings: heldValue,
// ...
};
Pretenured means “allocate directly in old space, skip the nursery.”
In September 2025, V8 commit 5abdd62d579b by Omer Katz removed this flag:
const cell = new WeakCell{
map: GetWeakCellMap(),
finalization_registry: finalizationRegistry,
target: target,
holdings: heldValue,
// ...
};
The commit message: “Young WeakCells are supported out of the box and there’s no correctness need to pretenure them. Having WeakCell allocated as young also simplifies followup planned changes.”
The associated bug is chromium:340777103: “FinalizationRegistry-s are treated as strong roots on minor GCs.”
Young generation vs old generation
V8’s heap is split into two regions with different garbage collection strategies.

The young generation (also called the nursery) is small - a few megabytes. Most objects are allocated here. V8 collects it with the scavenger (minor GC), which runs fast and frequently. Objects that survive a scavenge get promoted to old generation.
The old generation is large - up to whatever --max-old-space-size allows. It’s collected by the mark-compact collector (major GC), which is slower but thorough. Long-lived objects end up here.
Most of the time, short-lived objects are born in young space, die there, and the scavenger reclaims them cheaply. That’s the generational hypothesis: most objects die young.
Pretenured bypasses this entirely. The object goes straight to old generation, skipping the nursery. V8 originally did this for WeakCell because “WeakCell weakness handling is only implemented in the old space” - the scavenger didn’t know how to deal with them. The comment in the code had a TODO saying to fix this eventually.
When V8 did add scavenger support for WeakCells, they removed Pretenured. WeakCells now land in young generation like everything else.

Why this causes the leak
The bug title says it: “FinalizationRegistry-s are treated as strong roots on minor GCs.”
During a scavenge (minor GC), the FinalizationRegistry treats its active WeakCell entries as strong roots. This means the scavenger won’t collect them - it promotes them to old generation instead. So the cycle looks like:
AbortSignal.any()is calledFinalizationRegistry.register()creates aWeakCellin young generation- Minor GC runs - the
WeakCellis a strong root, so it survives and gets promoted - The composite signal (the
WeakCell’s target) also can’t be collected while theWeakCellis alive in young space - Eventually a major GC runs and clears everything
- But at high call rates, steps 1-4 repeat faster than step 5
With Pretenured, step 2 put the WeakCell directly in old space. There was no young-generation pressure, no promotion overhead, and old-space GC handled everything efficiently.
Proving it
We can prove this by patching V8 14.3 to restore Pretenured. One word change in deps/v8/src/builtins/finalization-registry.tq:
- const cell = new WeakCell{
+ const cell = new (Pretenured) WeakCell{
Rebuild Node and run the reproduction:
V8 14.3 (stock): V8 14.3 (Pretenured restored):
100k: 159 MiB 100k: 147 MiB
200k: 207 MiB 200k: 159 MiB
300k: 254 MiB 300k: 161 MiB
400k: 304 MiB 400k: 163 MiB
500k: 359 MiB (growing) 500k: 162 MiB (stable)
One word. That’s the difference between a stable 162 MiB and unbounded growth past 2 GB.
The heap snapshot
A heap snapshot after 200k calls + explicit GC on stock V8 14.3 shows what’s being retained:
| Size | Count | Object |
|---|---|---|
| 27.5 MiB | 400,011 | system / WeakCell (V8 internal) |
| 16.8 MiB | 200,002 | AbortSignal |
| 12.2 MiB | 400,026 | Map (EventTarget internals) |
| 12.2 MiB | 400,013 | WeakRef |
| 6.1 MiB | 200,022 | Set (kSourceSignals) |
200k AbortSignal objects survive GC. They should be unreachable, but the WeakCell entries keep them alive through the strong-root treatment during scavenges.
What to take away
-
FinalizationRegistryis not free. Eachregister()call creates a V8-internalWeakCell. On current V8, these WeakCells are young-generation objects treated as strong roots during minor GC. -
Don’t use
FinalizationRegistryin hot paths. If you’re callingregister()thousands of times per second, you’ll outpace the garbage collector. Use it as a safety net, not as your primary cleanup mechanism. -
When debugging memory leaks across Node versions, check the V8 version. The same JavaScript code can behave differently because of GC implementation changes that aren’t visible from the JS side.
-
Heap snapshots show V8 internals. The
system / WeakCellentries in a heap snapshot are not visible from JavaScript - they’re V8’s internal bookkeeping. But they consume real memory.