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:

  1. Create a new AbortSignal (the composite result)
  2. For each source signal, add a WeakRef to the result in kDependantSignals
  3. Register with a FinalizationRegistry so that when the composite signal is GC’d, the dead WeakRef gets cleaned up from the source’s kDependantSignals

Step 3 is where the problem lives. Each call to AbortSignal.any() calls FinalizationRegistry.register(), which creates a V8-internal WeakCell object.

How AbortSignal.any() accumulates WeakRefs and WeakCells over repeated calls

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.

V8 heap layout showing young generation and old generation

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.

Comparison of WeakCell allocation: Pretenured going to old space vs young generation pileup

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:

  1. AbortSignal.any() is called
  2. FinalizationRegistry.register() creates a WeakCell in young generation
  3. Minor GC runs - the WeakCell is a strong root, so it survives and gets promoted
  4. The composite signal (the WeakCell’s target) also can’t be collected while the WeakCell is alive in young space
  5. Eventually a major GC runs and clears everything
  6. 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

  1. FinalizationRegistry is not free. Each register() call creates a V8-internal WeakCell. On current V8, these WeakCells are young-generation objects treated as strong roots during minor GC.

  2. Don’t use FinalizationRegistry in hot paths. If you’re calling register() thousands of times per second, you’ll outpace the garbage collector. Use it as a safety net, not as your primary cleanup mechanism.

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

  4. Heap snapshots show V8 internals. The system / WeakCell entries in a heap snapshot are not visible from JavaScript - they’re V8’s internal bookkeeping. But they consume real memory.