Browser · intermediate

Browser heap grows after repeated interactions: compare retained heap snapshots

Browser heap growth after repeated interactions usually points to objects that remain reachable even when the UI no longer references them. The reliable diagnostic is to compare retained heap snapshots taken before and after a scripted interaction sequence, then walk the dominator tree to the GC roots holding the surplus alive. This guide explains the boundaries between UI lifecycle, JavaScript reference graphs, and browser-internal caches that commonly masquerade as leaks.

The symptoms

  • Heap size in the browser DevTools Memory tab trends upward across consecutive interactions, with no plateau after a reasonable settling period.
  • Performance Memory.usedJSHeapSize increases monotonically while the page is interacted with, even after interactions that should be idempotent.
  • Tab memory footprint in the browser task manager grows over minutes of use, while the visible DOM size stays roughly constant.
  • Garbage collection events in PerformanceObserver entries do not reclaim the growth, indicating retained rather than merely allocated memory.
  • Slowdown or jank appears after dozens of repeats even though the DOM is unchanged, suggesting listener or closure retention rather than layout cost.

Likely causes

  • Event listeners attached to window, document, or long-lived singletons are never removed when components unmount or panels close.
  • Closures over component state outlive their owning component because a long-lived registry, cache, or event bus keeps a reference.
  • Detached DOM subtrees are retained by JavaScript references after the visible node is removed, so the nodes cannot be collected.
  • Growing internal Maps or Sets used for memoization, deduplication, or WeakMap-keyed lookups that are actually keyed by long-lived strings.
  • Browser-side caches such as feature detection tables, font caches, or DevTools-controlled buffers grow with the interaction surface area.

First ten minutes

  1. 01Confirm the symptom is retention rather than allocation by opening the Performance panel and recording while you exercise the interaction; look for shrinking heap after forced garbage collection.
  2. 02Read Performance.memory (where available) and Timeline Memory panel at the start and after each batch to characterize whether the upward trend is gradual or stepwise.
  3. 03Decide whether the interaction is reachable from a bounded scope (a single component) or from a long-lived root (window, document, app store).
  4. 04Take a baseline heap snapshot, then write a deterministic script that performs the interaction N times and a second snapshot, then a third after a settle window.
  5. 05Annotate the snapshots with the user action string so the comparison can be reproduced by another engineer.
  6. 06Rule out obviously non-leak sources first: large background fetches, console-logged payloads, or DevTools heap-only buffers that skew the snapshot.

Evidence to collect

  • Two or more heap snapshots taken at the same DOM state, with the delta expressed in shallow and retained size for the same constructor families.
  • The count of objects per constructor between snapshots, ordered by retained size delta, not by instance count alone.
  • Dominator paths from the largest delta object back to a GC root, recorded alongside the snapshot label.
  • Performance Memory readings at each snapshot boundary, including usedJSHeapSize and totalJSHeapSize where the browser exposes them.
  • Listener counts on window, document, and the application root before and after the interaction sequence, captured via DevTools or instrumentation.

Where to look

  • Behavior boundary between the JavaScript reference graph and the DOM tree, especially nodes that appear in the Detached DOM bucket in the snapshot Summary view.
  • Boundary between component lifecycle and global registries, where addEventListener and removeEventListener calls must be paired or where cleanup hooks must run.
  • Boundary between application state stores and ephemeral views, where store entries that key on user-visible identifiers can outlive the views they describe.
  • Boundary between the document and the browser process, where image decoders, font caches, and indexed storage may grow with the DOM surface that the interaction touches.
  • Boundary between the in-page timer queue and the event loop, where setInterval, requestAnimationFrame, and observer callbacks can accumulate if their cancellation hooks are conditional.

Diagnostic steps

  1. 01Capture Snapshot 1 at rest, Snapshot 2 after N interactions, Snapshot 3 after a settle window with no further interaction, and Snapshot 4 after forcing GC in DevTools.
  2. 02Compare Snapshot 1 to Snapshot 2 in the Comparison view and sort by Retained Size delta; record the top constructors and their retained size growth.
  3. 03For each top constructor, expand an instance and read the Retainers path to find the immediate retainer and the chain leading to a GC root such as window or a detached subtree.
  4. 04Distinguish detached DOM retention from live DOM retention by checking whether the retainer path passes through a node that is still attached to the document.
  5. 05Group the retention causes: listener on long-lived target, closure over component state, cache entry keyed by long-lived string, or detached subtree kept by JavaScript.
  6. 06Cross-check the suspected cause with a memory instrumentation hook in the application code that logs addEventListener, addEventListener-equivalent, and explicit cache writes during the scripted run.
  7. 07Re-run the sequence with the suspected retention source disabled and confirm the retained size delta shrinks to within the noise floor of the measurement.

Common mistakes

  • Comparing snapshots taken at different DOM states, which conflates reachable DOM growth with retention and invalidates the delta.
  • Reporting instance count growth instead of retained size growth, which overstates the cost of small objects and understates the cost of large retained subtrees.
  • Trusting the heap snapshot Summary view alone without walking the Retainers path to a GC root, which can pin blame on a constructor that is itself retained by something else.
  • Forcing garbage collection from JavaScript without a real GC root walk, which can leave unreachable objects in the snapshot and distort the comparison.
  • Assuming Performance Memory is identical to retained heap; it reports allocated JS heap and can rise even when no objects are retained.

Safe fixes

  • If the dominator path ends at window or document through an event listener, add a paired removal in the component teardown path and verify the listener count returns to baseline after the scripted sequence.
  • If the dominator path ends at a closure over component state, move the long-lived reference into a store that is explicitly cleared, or convert it to a WeakMap keyed by the owning component instance.
  • If the dominator path is a detached DOM subtree, locate the JavaScript reference that outlives the visible node and null it in the same code path that removes the node from the document.
  • If the dominator path is a Map or Set keyed by a long-lived string, replace the key with a weak reference or scope the map to the component lifetime so that clearing the component clears the cache.
  • If the script confirms the growth is browser-side and not JavaScript-side, treat the result as evidence for a browser bug rather than an application bug and capture the snapshot for upstream reporting.

Prove the fix

  1. 01Re-run the same scripted interaction sequence against the patched build and confirm the retained size delta between Snapshot 1 and Snapshot 2 is within the noise floor defined by the unpatched run.
  2. 02Confirm the listener count on window, document, and the application root is identical before and after the sequence, with a tolerance of zero additions per scripted interaction.
  3. 03Confirm the Performance Memory usedJSHeapSize returns to the baseline band within the settle window, recorded for at least three consecutive runs.
  4. 04Confirm the GC root dominator path for the previously top constructor no longer appears in the Snapshot 1 to Snapshot 2 comparison after the sequence is run.
  5. 05Add a regression check that fails CI if the retained size delta of the targeted constructor family exceeds a documented threshold over the scripted interaction count.

Prevention and next steps

  • Establish a convention that every addEventListener site has a paired removal or is scoped to a lifecycle hook that is exercised by the test suite.
  • Use WeakMap and WeakSet for caches keyed by DOM nodes or component instances so that the cache cannot outlive its key.
  • Keep a heap snapshot baseline in the repository and refresh it when intentional growth is approved, so future deltas have a known reference.
  • Add a scripted interaction loop to the performance test that asserts a retained size bound for the top constructors after a fixed number of repetitions.

Safe commands and checks

Take a heap snapshot and label it with the user action string; repeat the action N times and take a second snapshot with a matching label that encodes the iteration count.
Force garbage collection from the DevTools Memory tab before taking each snapshot so that the comparison reflects retained objects rather than unreachable garbage.
Open the Comparison view and sort by Retained Size delta so the largest sources of retention appear at the top regardless of instance count.
Read the Performance Memory API values at the same instants as the snapshots and record them alongside the snapshot labels for cross-reference.
Subscribe to PerformanceObserver entries for the entry types that the browser exposes in this build and record the GC and allocation events during the scripted run.
Use the platform's getEventListeners-equivalent introspection or application instrumentation to count listeners on window, document, and the application root before and after the sequence.