Browser · intermediate
How to verify a browser memory regression against a baseline
This guide shows engineers how to verify a browser memory regression against a captured baseline. It focuses on the failure mode where repeated workflows in a web application retain an unbounded heap instead of converging to a steady state, and walks through disciplined baseline capture, controlled replay, and statistical comparison.
The symptoms
- •Heap usage after N identical workflow iterations grows by more than the per-iteration delta observed in the baseline capture window.
- •Garbage collection does not reclaim the retained set: post-GC heap size remains materially above the baseline's post-GC line even after idle waits.
- •Detached DOM nodes, event listeners, or closure-captured references accumulate when the same component is mounted, exercised, and unmounted in a loop.
- •Performance.memory.usedJSHeapSize and totalJSHeapSize climb across cycles rather than oscillating around a bounded band, while jsHeapSizeLimit stays constant.
- •User-visible stalls, tab suspension, or out-of-memory tab crashes appear only after many repetitions of a workflow that previously ran in a stable memory envelope.
Likely causes
- •Listeners or timers registered against detached subtrees that keep DOM nodes reachable through the listener's closure.
- •Caches, Maps, or WeakRef-promoted structures silently upgraded to strong references when a code path stores the wrong key type.
- •Long-lived singleton objects accumulating per-workflow state because a teardown path is missing or runs only on certain branches.
- •Frameworks retaining component instances, fiber nodes, or virtual DOM entries due to interrupted transitions or uncaught promise rejections.
- •Third-party scripts appending elements or observers that the host page never removes on workflow completion.
- •Heavy object retention from debug builds or instrumentation that is absent from the baseline, skewing the comparison upward.
First ten minutes
- 01Confirm the regression report names the same build under test and the same workflow that produced the baseline; if either differs, stop and rerun baseline capture rather than comparing.
- 02Open the Performance tab and start a recording that brackets one full workflow iteration; mark heap snapshots before and after to anchor a single cycle.
- 03Capture Performance.memory.usedJSHeapSize, totalJSHeapSize, and jsHeapSizeLimit at start, mid-cycle, and post-idle for one iteration using the Performance API documented by MDN.
- 04Trigger a forced GC if the browser exposes one in devtools (e.g., the trash icon on the Memory panel) and record the post-GC heap; this becomes the comparison anchor, not the live high-water mark.
- 05Repeat the workflow five to ten times in a controlled loop, sampling heap at the same checkpoints each cycle; the trend across cycles is the signal, not any single sample.
- 06Save the recording and snapshot files with the exact commit hash, build profile, and iteration count embedded in the filename so the comparison is reproducible.
Evidence to collect
- •Pre-fix and post-fix Performance.memory samples at fixed workflow checkpoints, with timestamps and build identifiers in the filename.
- •Heap snapshots at cycle 1 and cycle N before and after the change, retained-size deltas per constructor, and the GC root path for any remaining growth.
- •Detached-elements count after workflow teardown for both builds, captured with the same browser profile and extension set as the baseline.
- •Allocation timeline per-iteration byte totals across N cycles, plotted alongside the baseline median band.
- •Sign-off entry that names the exact cycle count, build hash, and verdict so future regressions can be triaged against this verified state.
Where to look
- •The browser's Memory panel boundary between "Heap snapshots" and "Allocation instrumentation" — use snapshots for retention shape, timeline for per-iteration rate.
- •The Performance panel's recording timeline at the user-driven interaction markers, where heap deltas align with specific handlers.
- •DevTools' "Memory" → "Detached elements" view, which exposes DOM nodes whose window/document link is broken but which still hold listeners.
- •The Performance API surface documented by MDN, particularly the memory attribute and Performance.measure entries for custom marks at workflow boundaries.
- •Console error and unhandledrejection channels, since uncaught rejections can leave async retainers in place across cycles.
- •The boundary between application code and third-party scripts, where injected elements or observers often outlive their creators.
Diagnostic steps
- 01Establish the baseline replay script that drives the workflow N times with deterministic input and the same browser profile; reuse it for both baseline and candidate runs to eliminate input drift.
- 02Plot usedJSHeapSize at the same checkpoint across N cycles; if the slope is positive and exceeds a small per-cycle budget (chosen from baseline noise), flag the regression before reading any snapshot.
- 03Compare the post-GC heap at cycle N against the post-GC heap at cycle 1; convergence to a steady band indicates bounded retention, divergence indicates a leak path.
- 04Diff cycle-N and cycle-1 snapshots by retained size per constructor; the constructors with the largest positive delta narrow the search to specific code paths.
- 05For each top-growing constructor, follow the retaining path up to GC roots; the path identifies the long-lived owner rather than the freshly allocated node.
- 06Cross-check the retaining path against the detached-elements view; if a detached subtree retains listeners or timers, the leak boundary sits on the unmount path, not the allocator.
- 07Repeat the comparison with third-party scripts disabled to isolate first-party retention; a regression that disappears points at script injection rather than host code.
- 08Record the verdict as pass, fail, or inconclusive with the exact cycle count and build identifiers so future reruns can be reconciled.
Common mistakes
- •Comparing the live high-water heap instead of the post-GC heap, which lets transient allocations masquerade as retention.
- •Running baseline and candidate on different machines, browser versions, or with different extensions, so the comparison reflects environment drift rather than code.
- •Reading a single heap snapshot instead of a series across cycles, which cannot distinguish a leak from a one-time warm-up of caches and JIT code.
- •Trusting the "retained size" of a fresh node rather than its retaining path up to a root, which points at the wrong code location.
- •Mutating the workflow between captures (new features, debug panels open, devtools attached) so the heap footprint includes instrumentation not present at baseline.
- •Treating a regression as fixed after one shorter run, without re-running the full N-cycle loop, missing slow leaks that only appear past a threshold iteration count.
Safe fixes
- •If the regression is reproducible only with devtools attached, gate verification on a clean profile run; do not "fix" instrumentation-induced retention by closing tools during the test.
- •If detached subtrees retain listeners, require that the unmount path remove every listener and observer the mount path registered, verified by a per-node listener count check post-unmount.
- •If a long-lived singleton accumulates per-workflow state, add a teardown entry point that clears the relevant Map or array, and assert post-teardown heap size returns to within baseline noise.
- •If third-party scripts append elements, wrap their mount points with a guard that records insertion and removes children on workflow completion; verify by inspecting the host container after N cycles.
- •If the fix relies on weak references, verify by forcing GC and observing that the post-GC heap drops; if it does not, the reference is effectively strong and the fix is incomplete.
- •Land each candidate fix behind a flag and rerun the full baseline replay before and after; only declare success when the post-flag curve matches the baseline curve within the agreed noise band.
Prove the fix
- 01Re-run the N-cycle baseline replay on the candidate build and confirm that usedJSHeapSize at the post-GC checkpoint of cycle N is within the baseline's noise band of the same checkpoint at cycle 1.
- 02Confirm that the heap snapshot diff between cycle N and cycle 1 shows no constructor with a retained-size delta exceeding the per-cycle budget defined at baseline capture.
- 03Confirm that the "Detached elements" count at cycle N is zero, or at most equal to the baseline's detached count, after the same workflow teardown steps.
- 04Confirm the allocation timeline's per-iteration byte count oscillates around the baseline median rather than trending upward across cycles.
- 05Confirm the regression report is re-run end-to-end with the candidate build, the same workflow, and the same N; record the verdict in the same artifact store as the baseline.
Prevention and next steps
- •Keep the N-cycle replay script under version control and rerun it on every release candidate; treat any out-of-band growth as a release blocker until explained.
- •Define a per-cycle heap budget in the same artifact as the baseline, derived from baseline noise, so reviewers do not negotiate thresholds per incident.
- •Require unmount paths to remove listeners, timers, and observers registered on mount; enforce via a per-node listener-count assertion in the replay script.
- •Disable third-party script injection during verification and gate production re-enablement behind a separate memory budget check.
- •Capture baselines only on clean profiles with devtools detached, and store the profile snapshot alongside the heap artifacts to prevent environment drift.
Safe commands and checks
performance.mark('workflow-start'); performance.mark('workflow-end'); performance.measure('workflow', 'workflow-start', 'workflow-end');
const m = performance.memory; console.log({ used: m.usedJSHeapSize, total: m.totalJSHeapSize, limit: m.jsHeapSizeLimit });
performance.measureUserAgentSpecificMemory().then(r => console.log(r.breakdown.filter(x => x.attribution.length)));
new PerformanceObserver(list => { for (const e of list.getEntries()) console.log(e.name, e.duration); }).observe({ type: 'event', buffered: true, durationThreshold: 0 });
document.querySelectorAll('*').forEach(n => { if (n.__buglyst_listeners && n.__buglyst_listeners.length) console.log(n, n.__buglyst_listeners.length); });
performance.mark('cycle-' + cycleIndex + '-start'); /* workflow */; performance.mark('cycle-' + cycleIndex + '-end');
getEventListeners(document.querySelector('#suspect-root')); // available in devtools console for the selected node
performance.measure('cycle', { start: 'cycle-' + cycleIndex + '-start', end: 'cycle-' + cycleIndex + '-end', detail: { heap: performance.memory.usedJSHeapSize } });