Browser · intermediate
Browser heap-growth checklist
A practical triage checklist for engineers facing browser heap usage that climbs with each repeated workflow. This guide frames heap growth as an evidence problem, not a vibes problem: you collect measurements before mutating any code path, then attribute growth to a specific boundary (allocation site, retention path, or detached DOM) using Performance, Memory, and Heap Snapshot tooling from MDN.
The symptoms
- •JS heap size in Performance Memory panel climbs monotonically across repeated runs of the same workflow, then drops only on a full page reload, not on route changes.
- •Detached HTMLCollection length or detached node count in the Memory panel's Heap Snapshot summary rises over time even after the parent view is unmounted.
- •Task Manager or browser process memory shows the renderer process RSS expanding over a session of repetitive actions such as opening modals, scrolling long lists, or replaying a SPA route.
- •Long-task warnings increase alongside heap growth, suggesting retained allocations are forcing more frequent major garbage collection rather than reclaiming memory.
- •After navigation, retained size for specific constructors (Detached HTMLDivElement, Closure, ArrayBuffer) stays high instead of falling back to baseline.
Likely causes
- •Event listeners attached via addEventListener on window, document, or a long-lived singleton without a corresponding removeEventListener tied to component teardown.
- •Detached DOM subtrees held by JavaScript references after a framework unmounts a view, typically because an observer, cache, or external library retained a node.
- •Unbounded in-memory caches keyed by user input, request payloads, or route params that only ever push entries and never evict.
- •Closures capturing large scope variables (large arrays, DOM nodes, timers, or third-party instances) where the closure outlives the intended lifecycle.
- •WebSocket, EventSource, or fetch Streams kept open without explicit abort/close on view destruction, each holding parsing buffers and listener slots.
- •Worker scopes retaining transferable buffers, IndexedDB cursors, or BroadcastChannel handles across page interactions without explicit termination or unregistration.
First ten minutes
- 01Open Chrome DevTools, navigate to Performance > Memory, and confirm whether performance.memory.usedJSHeapSize is trending upward while totalJSHeapSize is not, indicating retained growth rather than a one-time spike.
- 02Reproduce the suspected workflow once while watching the Memory tab; note the heap value, then reproduce three more times and record whether the value returns to the same baseline each cycle.
- 03In the Memory panel take a Heap Snapshot labeled "baseline" before the workflow, then a second snapshot labeled "after workflow N" once the workflow completes; do not force a garbage collection before the second snapshot until you have observed the natural pattern.
- 04Compare the two snapshots using the Comparison view, sort by Size Delta, and identify the top three constructors whose retained or shallow size grew the most.
- 05Switch the snapshot view from Summary to Containment and inspect the largest delta path; record the retaining path from the root down to the leaking constructor.
- 06Cross-reference the retaining path against your code: module name, class name, event listener registration site, cache key, or framework lifecycle hook that should have released the reference.
- 07Decide before writing code whether the evidence points to detached DOM, listener retention, cache growth, or closure capture, because the fix differs for each and the proof-of-fix measurement also differs.
Evidence to collect
- •Two labeled Heap Snapshots (baseline vs after-N-iterations) with Size Delta for top constructors in the Comparison view, exported as .heapsnapshot files with timestamps.
- •The retaining path from GC roots to each leaking object, captured from the Containment view, identifying the variable name, map entry, or array index that holds the reference.
- •Allocation instrumentation records from Performance > Memory > "Record allocation timeline" showing which functions allocated the most bytes during the workflow.
- •Event listener inventory from the Console: getEventListeners(target) on window, document, and key long-lived nodes, captured before and after the workflow, compared for listener count delta.
- •List of open handles from chrome://inspect or the browser's internal task tracker if available, documenting which streams, observers, and timers survive across iterations.
Where to look
- •The DevTools Performance Memory panel boundary, where performance.memory exposes usedJSHeapSize, totalJSHeapSize, and jsHeapSizeLimit on supporting engines per the MDN Performance documentation.
- •The Memory panel Heap Snapshot Comparison view boundary, where size delta between two snapshots is the only legitimate signal of retained growth versus transient allocation.
- •The component lifecycle boundary in the framework you use (React useEffect cleanup, Vue onUnmounted, Angular ngOnDestroy, Svelte onDestroy), where missing cleanup is the dominant leak vector for SPAs.
- •The DOM-to-JS reference boundary, observable via detached node entries in the Heap Snapshot Summary under the "(Detached)" constructor group.
- •The module-singleton boundary where module-scoped Maps, Sets, WeakMaps, and arrays live for the lifetime of the page; these survive HMR and route changes in development.
- •The long-task boundary in Performance recordings, where growing heap pressure manifests as more frequent major GC pauses exceeding 50 ms.
Diagnostic steps
- 01Reproduce the suspected workflow N times (start with N=5), capture performance.memory snapshots, and verify that the post-workflow usedJSHeapSize exceeds the baseline by more than the noise floor (treat ~5% as noise, more than ~15% as signal).
- 02Take a Heap Snapshot before the workflow and a second one after the final iteration, then in Comparison view sort by Size Delta descending; the top three delta rows are the candidates, not the absolute largest constructors.
- 03For each top delta constructor, switch to the Containment view and follow the retaining path; the node at depth one below (GC roots) is the actual leak, and naming that path is required before changing code.
- 04Distinguish detached DOM growth from in-memory object growth: detached nodes appear under a "(Detached)" group, while plain JS growth appears under named constructors without the detached prefix.
- 05Run getEventListeners(window) and getEventListeners(document.body) in the Console before and after the workflow; a non-zero delta in listener count confirms listener retention as the cause.
- 06Inspect module-scoped caches by adding temporary logging or breakpoints at the cache.set call site; if cache size grows linearly with workflow iterations without an eviction path, the cache is the cause.
- 07Use the Allocation Instrumentation Timeline to map top allocating functions to source files and lines, then cross-check whether those functions are reached more times per workflow iteration than expected.
- 08Repeat the entire sequence in an incognito profile with extensions disabled to rule out extensions or service worker caches as the source of growth before attributing it to your code.
Common mistakes
- •Reading the Memory tab while a forced garbage collection is in progress and treating a transient dip as proof of a leak; the comparison view between two snapshots is the only valid signal.
- •Pointing at the largest constructor in the Summary view rather than the largest Size Delta in the Comparison view, which conflates expected steady-state memory with newly retained memory.
- •Concluding "no leak" because usedJSHeapSize returned to baseline after a navigation, when the relevant boundary is repeated SPA route changes that do not trigger a full page reload.
- •Removing all event listeners from a node globally without confirming which subscribers own them, which breaks legitimate cross-feature coordination and masks the real retaining path.
- •Trusting weak references alone to prevent leaks without verifying that the keys used are actually object identities that the collector can null; primitive keys in WeakMap never collect.
- •Refactoring a cache to use an LRU policy without measuring whether eviction actually triggers during the workflow, leaving the leak shape unchanged but harder to spot.
Safe fixes
- •If the retaining path shows a listener on window or document registered in an effect, add a matching removeEventListener in the cleanup function and verify by getEventListeners(window) returning zero additional listeners after teardown.
- •If the retaining path shows detached nodes held by a cache, replace the cache key with the live component instance (a WeakMap keyed by the node) so the GC can reclaim the subtree when the component is gone.
- •If a module-scoped Map grows without bound, introduce a bounded structure (LRU with a documented cap) and assert in a unit test that the map size stays under the cap after N synthetic inserts.
- •If a closure captures a large array, move the large array to module scope so it is shared rather than duplicated per closure instance, and confirm retained size for that array stays flat across iterations.
- •If a fetch, EventSource, or WebSocket is held open, store the AbortController in component state and call controller.abort() in the teardown hook; verify the connection closes via the Network panel showing no pending WS or SSE entries.
- •If a ResizeObserver, MutationObserver, or IntersectionObserver is the retaining path, call observer.disconnect() in cleanup and confirm the observer no longer appears in the snapshot's retainers list.
Prove the fix
- 01Re-run the original N-iteration workflow with the fix in place and capture two Heap Snapshots (baseline and after-N) in the Comparison view; required pass condition is that Size Delta for the previously leaking constructor returns to within the noise floor (~5%) of zero.
- 02Confirm performance.memory.usedJSHeapSize returns to within a small tolerance of the pre-workflow baseline after a natural garbage collection cycle, observed by waiting for two consecutive GC events in the Performance recording.
- 03Confirm via getEventListeners(window) and getEventListeners(document) that listener counts do not grow across N iterations, with an explicit assertion in a debug-only script that the delta is zero.
- 04Confirm the long-task count and total major GC time in the Performance recording do not increase relative to the pre-fix baseline recording of the same workflow.
- 05Re-run the workflow in an incognito profile and in a second browser engine if available to ensure the fix is not an artifact of one engine's GC heuristics, and store the recordings as evidence.
Prevention and next steps
- •Adopt a code-review checklist item that every addEventListener, observer, setInterval/setTimeout, and stream subscription has a paired removal or abort in the same lifecycle hook, and reject PRs that miss the pairing.
- •Favor WeakMap and WeakSet keyed by live object references for caches and registries, and forbid primitive keys in these structures via lint rule, because only object keys permit collection.
- •Add an automated memory smoke test that runs the primary user workflow N times in a headless browser and asserts that usedJSHeapSize growth stays below a documented percentage threshold, gating CI on the assertion.
- •Keep a documented cap and eviction policy for every module-scoped Map, Set, and array, and surface the cap in the code so reviewers can verify the policy matches the workflow's expected load.
- •Periodically re-record a Heap Snapshot Comparison for the top user journeys and archive it with the build artifact, so retention regressions are visible alongside code changes rather than only after a production complaint.
Safe commands and checks
Open DevTools and capture two Heap Snapshots in the Memory panel, then switch to the Comparison view and sort by Size Delta. In the Performance panel, enable the Memory checkbox and record the workflow; observe usedJSHeapSize and the major GC markers in the timeline after recording. In the Console, run getEventListeners(window) and getEventListeners(document) before and after the workflow to diff listener counts. In the Console, run getEventListeners(<element>) for a long-lived DOM node identified in the snapshot's retaining path. Use the Allocation Instrumentation Timeline in the Memory panel to record during the workflow and inspect top allocating functions and their source locations.