Measured Results

Chrome for Testing 149.0.7827.55 (arm64 macOS), driven by playwright-core over CDP. Reproduce with cd ../src && npm run all. GC is forced via HeapProfiler.collectGarbage (two rounds) before every measurement.


1. Closure context retention

200 retained closures, each created in a scope holding a 50,000-element array. Arrays alone would cost ~76 MB if all were retained.

VariantReturned closureRetained heap
A — no sibling references big() => 420.01 MB
B — unused, never-called sibling references big() => 4238.17 MB
C — sibling present, plus big = null() => 420.01 MB

B retains 5,744× what A retains. The returned closure is identical in all three variants. Only the other declarations in the scope differ.

V8 allocates one Context per scope. A variable referenced by any closure in that scope becomes context-allocated, and every sibling closure retains the whole context.


2. Detached DOM, and the wrong instrument

5,000 <div> nodes built, attached, detached, then released.

Stagedetached nodesdetached bytesJS heap
built + attached00.0 KB0.84 MB
removed from DOM, JS refs held5000507.8 KB0.84 MB
JS refs released00.0 KB0.74 MB

Releasing 507.8 KB of DOM moved the JS heap by 0.10 MB.

DOM nodes live in Blink's C++ heap. Runtime.getHeapUsage and performance.memory report the JS heap only. A team checking performance.memory, seeing it flat, and concluding "no leak" has been misled by the instrument. Use the snapshot detachedness field or performance.measureUserAgentSpecificMemory().


3. Event listener leak — 300 mount/destroy cycles

Every variant constructs identical widgets and calls destroy(). Only listener lifecycle differs. Each variant runs on a fresh page (see harness note below).

Variantretained JS heapdetached nodes
anonymous listener, never removed23.00 MB300
removeEventListener in destroy()0.08 MB0
AbortController signal + abort()0.09 MB0

The anonymous-listener variant cannot be repaired later: there is no reference to the function that was registered. The AbortController form creates the teardown handle at registration time, which is why it is the better default — it is not faster, it is harder to get wrong.


4. Map vs WeakMap — 2,000 detached nodes as keys

Side tableretained JS heapdetached nodes still alive
Map38.34 MB2000
WeakMap0.09 MB0

Identical code except the constructor. A Map holds keys strongly, so every node it has ever seen is immortal, along with its metadata — unreachable from the document and from application code, and still not collectable.


5. Leak-detection method — 12 cycles × 60 views, GC between each

clean  ▁▁▁▁▁▁▁█████  0.68MB -> 0.69MB     slope 0.001 MB/cycle   R² 0.742
leaky  ▁▁▂▃▃▄▅▆▆▇██  2.51MB -> 22.71MB    slope 1.836 MB/cycle   R² 1.000

The clean run has R² = 0.742. Noise can look strongly correlated over a short series — which is exactly why slope alone is not evidence. The discriminator is the pair:

slopeverdict
clean0.001 MB/cycle0.742correlated noise at zero magnitude
leaky1.836 MB/cycle1.000linear growth driven by a repeated operation

Rules this encodes:

  1. Force GC between cycles, or you are measuring GC scheduling, not retention.
  2. Use identical repeated cycles, or slope is meaningless.
  3. Report slope and linearity. Neither alone supports a conclusion.

A high slope with low R² is usually a cache warming, a lazily-compiled path, or a heap that has not settled. Reporting that as a leak costs someone else a week.


Harness failure mode found while building this

Shared page across variants silently voids the experiment. The first listener-leak run measured all three variants on one page. The leaked widgets from variant 1 remained alive during variants 2 and 3, so the detached column read 300 / 300 / 300 — identical for the fixed variants and the broken one. The JS-heap column was still correct, which made the table look partially plausible and therefore harder to distrust.

Fix: a fresh page (fresh realm, fresh heap) per variant.

This is the same class of bug as the realm-reuse issue in fe-01: the harness produced clean, plausible, wrong data. Deliberately break the thing being measured and confirm the harness notices, before trusting any number it reports.