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

  1. 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.
  2. 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.
  3. 03Capture Performance.memory.usedJSHeapSize, totalJSHeapSize, and jsHeapSizeLimit at start, mid-cycle, and post-idle for one iteration using the Performance API documented by MDN.
  4. 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.
  5. 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.
  6. 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

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.
  7. 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.
  8. 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

  1. 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.
  2. 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.
  3. 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.
  4. 04Confirm the allocation timeline's per-iteration byte count oscillates around the baseline median rather than trending upward across cycles.
  5. 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 } });