Observation Guide
Choosing the instrument first
Most failed leak investigations are instrument-selection failures. Decide what you are looking at before you look.
| Instrument | Sees | Blind to | Use when |
|---|---|---|---|
performance.memory (Chrome, deprecated) | JS heap size | all DOM, workers, other frames | quick triage only; never as evidence |
Runtime.getHeapUsage (CDP) | JS heap used/total | same | scripted regression checks |
| Heap snapshot | JS heap + native/DOM nodes, detachedness, retainer paths | cross-process | the real tool; anything you will act on |
performance.measureUserAgentSpecificMemory() | whole renderer incl. DOM | needs COOP+COEP | production RUM, when you can isolate |
| DevTools Performance → Memory checkbox | heap over time, node/listener counts | no retainer paths | seeing the shape before you snapshot |
Rule: if a claim about a leak rests on performance.memory, it rests on nothing. Measured in
this module: 508 KB of detached DOM freed moved the JS heap by 0.10 MB.
The DevTools Memory panel workflow
The workflow that finds leaks, in the order that works:
- Establish the repeated action. Open modal → close modal. Navigate → back. It must be idempotent: after one cycle the app should be in the same logical state.
- Warm up. Run the action 3–5 times first. Caches, lazy compilation and first-run allocation are not leaks, and skipping this step is how false positives are born.
- Snapshot 1. DevTools forces a GC before each snapshot, so you do not need to.
- Run the action N times (10–20).
- Snapshot 2.
- Comparison view, sort by Delta. You are looking for a class whose count grew by exactly N or a multiple of it. That proportionality is the signal — noise does not come in multiples of your loop count.
- Select an instance → Retainers panel. This is the finding. Walk the path to a root.
- Filter by
Detachedin the class filter to isolate detached DOM directly.
Reading the columns
- Shallow size — the object itself. Usually uninteresting.
- Retained size — what would be freed if it became unreachable. This is the number. A small object with huge retained size is a dominator, and dominators are where fixes go.
- Distance — hops from a GC root. Very short distances on objects that should be transient are suspicious.
- Delta (comparison view) — count and size change between snapshots. Sort here first.
Retainer paths worth recognising instantly
window → myModuleCache (Map) → {…} → HTMLDivElement side-table leak
window → (event listeners) → onResize() → Widget → data listener leak
window → (timer) → interval callback → context → Widget timer leak
Window → context → sibling closure → big array shared-context leak
document → … (nothing) + Detached HTMLDivElement detached DOM
The last one is diagnostic: an element flagged Detached whose retainer path starts at a JS
object rather than the document is the definition of the leak.
Healthy vs unhealthy signals
Healthy:
- Heap returns to roughly its baseline after each cycle, once warmed
- Detached node count returns to 0 after teardown
- Listener count stable across mount/unmount cycles
- Growth slope near zero regardless of R²
Unhealthy, with the diagnosis:
- Class count growing by exactly N per N cycles → deterministic per-iteration retention
- Detached nodes accumulating → something holds a reference to removed DOM
- Listener count rising monotonically → missing teardown, and the target is long-lived
- Heap sawtooths but the troughs rise → real growth hidden under normal GC activity; look at post-GC minima, not peaks
- Major GC frequency rising over a session → the
fe-01link: this presents as jank, not as a memory alert
What these measurements do not tell you
- One browser, one version. Retention behaviour is engine-specific; the closure/context result in particular is a V8 implementation detail, not a language guarantee.
- Synthetic cycles are not user sessions. Real leaks often need a specific navigation order or an error path that a loop never exercises.
- Forced GC is not real GC timing. It removes scheduling noise deliberately — which is what makes retention measurable, and what makes these numbers not a model of production pause behaviour.
- Snapshots perturb what they measure. Taking one forces a GC and pauses the renderer; do not put snapshots inside the loop you are timing.
- Nothing here covers workers, iframes, or other processes. Each has its own heap, and cross-process retention is invisible to all of the above.