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.
| Variant | Returned closure | Retained heap |
|---|---|---|
A — no sibling references big | () => 42 | 0.01 MB |
B — unused, never-called sibling references big | () => 42 | 38.17 MB |
C — sibling present, plus big = null | () => 42 | 0.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.
| Stage | detached nodes | detached bytes | JS heap |
|---|---|---|---|
| built + attached | 0 | 0.0 KB | 0.84 MB |
| removed from DOM, JS refs held | 5000 | 507.8 KB | 0.84 MB |
| JS refs released | 0 | 0.0 KB | 0.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).
| Variant | retained JS heap | detached nodes |
|---|---|---|
| anonymous listener, never removed | 23.00 MB | 300 |
removeEventListener in destroy() | 0.08 MB | 0 |
AbortController signal + abort() | 0.09 MB | 0 |
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 table | retained JS heap | detached nodes still alive |
|---|---|---|
Map | 38.34 MB | 2000 |
WeakMap | 0.09 MB | 0 |
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:
| slope | R² | verdict | |
|---|---|---|---|
| clean | 0.001 MB/cycle | 0.742 | correlated noise at zero magnitude |
| leaky | 1.836 MB/cycle | 1.000 | linear growth driven by a repeated operation |
Rules this encodes:
- Force GC between cycles, or you are measuring GC scheduling, not retention.
- Use identical repeated cycles, or slope is meaningless.
- 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.