Browser · beginner

Browser memory climbs after navigation: find objects surviving route changes

Diagnose climbing browser memory after SPA route navigation by separating routing artifacts from real object retention. The guide frames every symptom around an observable measurement, every fix around a release checkpoint, and treats retained-but-unreachable state as the working hypothesis before any speculative cause is accepted.

The symptoms

  • JS heap size reported by performance.memory.usedJSHeapSize climbs monotonically across successive route changes even though the user is not actively interacting with the new route.
  • Detached DOM node counts in browser heap snapshots grow after navigating away from a list, editor, or media-heavy view that mounted many components.
  • window/document listener counts (addEventListener spy or heap snapshot retainers) rise after each transition, indicating listeners attached during one route still execute during another.
  • setTimeout / setInterval handles returned from window.setTimeout persist in the Retainers panel of a heap snapshot after the originating component was supposed to be unmounted.
  • App slows, jank rises, or the tab is reloaded silently after extended navigation, suggesting the browser has been page-cycling to reclaim memory.
  • performance.measure user-timing marks for the post-navigation render stay flat while the heap grows, separating performance regressions from retention regressions.

Likely causes

  • A singleton store, event bus, or pub/sub object subscribes components during one route but never unsubscribes, so each navigated component adds a node to a long-lived collection.
  • setInterval or requestAnimationFrame loops registered inside a route component are not cleared in the unmount/dispose path, keeping closures over component state reachable.
  • Caches such as in-memory LRU maps, query caches, or WeakRef-pretending maps keyed by component identity accumulate entries when navigation reuses keys instead of evicting.
  • Framework routers retain the previous route's component tree in a transition wrapper or transition-group buffer, holding DOM nodes until the animation completes.
  • Third-party widgets (analytics, chat, media players) attach listeners to window or document at script load and never detach them across navigations.
  • DOM references captured in long-lived closures such as a scroll handler, IntersectionObserver, or ResizeObserver keep detached subtrees alive after the route unmounts.

First ten minutes

  1. 01Reproduce by opening the app, navigating the same sequence of routes 10–20 times, and capturing performance.memory after each transition; record the values to confirm monotonic growth rather than sawtooth GC behavior.
  2. 02Open the browser's Performance Monitor (if present) and watch the JS heap line during navigation; a steady upward slope over multiple transitions is the canonical signal, distinct from normal GC oscillation.
  3. 03Take two heap snapshots (Heap 0 before navigation, Heap 1 after 10 round trips), then compare snapshots and sort retained size descending to surface the largest retained objects.
  4. 04While snapshots are open, count event listeners per node via devtools or a small instrumentation snippet; a growing listener count on window/document after each route indicates unsubscribed subscriptions.
  5. 05Record timing marks around each navigation transition with performance.mark / performance.measure so retention issues are not conflated with render-time regressions.
  6. 06Stop adding instrumentation before drawing conclusions; further diagnostics should be read-only against the captured snapshots and timing data.

Evidence to collect

  • Two heap snapshots: one at app idle before navigation, one after N identical route round-trips, taken with the same heap snapshot trigger and the same pause-on-snapshots setting.
  • performance.memory values for usedJSHeapSize, totalJSHeapSize, and jsHeapSizeLimit sampled at the same points in the navigation sequence.
  • Listener counts on window and document measured by getEventListeners via devtools, or an instrumented wrapper of addEventListener / removeEventListener.
  • User-timing marks for navigation start, route module load, and first paint after route, so render time can be separated from retention.
  • Retainer paths from the top retained objects in Heap 1 back to GC roots, naming the closure, store, or DOM node that anchors each retention chain.
  • Source map of the leaked retainer path, so the fix can target the exact file and line that owns the subscription.

Where to look

  • The Retainers panel of a Chrome / Edge / Firefox heap snapshot, expanding from GC roots through 'closure' and 'object' edges to the route's component instances.
  • The framework's route-outlet and transition wrapper code, where prior route trees are sometimes held until a transition or animation completes.
  • Module-level singletons: event buses, query caches, analytics clients, websocket clients, and feature flag stores declared outside of any route lifecycle hook.
  • Top-level scopes of route entry files; side-effect imports that register listeners on the global window at evaluation time, not inside a lifecycle hook.
  • Boundary between mounted route and the document: IntersectionObserver, MutationObserver, ResizeObserver, scroll/resize handlers, and third-party widgets attaching via portals.
  • The timing marks recorded during firstTenMinutes, so any retention conclusion is anchored to a known route transition rather than to ambient background work.

Diagnostic steps

  1. 01Sample performance.memory.usedJSHeapSize across N round-trips of the same route sequence; classify the trend as flat (GC oscillation, not retention), rising-then-flat (one-time growth, likely initial cache fill), or monotonically rising (active retention leak).
  2. 02Take a baseline heap snapshot after a forced GC, then a second snapshot after the same navigation sequence plus another forced GC; the delta isolates retention from normal heap slack.
  3. 03In the second snapshot, sort objects by retained size and identify the top retainer; trace its shortest path to a GC root to determine whether the root is the window, a global store, a closure, or a single DOM node.
  4. 04For each suspect retainer, record its constructor name and a sample identity hash to detect duplicates across snapshots; multiple identical-shape closures from different routes is a strong indicator of per-route accumulation.
  5. 05Inspect the route's mount and unmount hooks side by side; a missing or asymmetric clearInterval / clearTimeout / observer.disconnect() on unmount maps directly to the suspect retainer.
  6. 06Use the devtools 'Event Listeners' panel to inspect listeners on window and document and correlate their handler source with a route module that no longer renders; cross-check with Heap 1 retainer paths.
  7. 07Cross-check with user-timing data: if a post-navigation render mark lengthens across trips, suspect retained DOM nodes; if the mark is stable and only the heap climbs, suspect singleton stores or timers.
  8. 08Stop the diagnosis once a single retainer class accounts for most of the delta; further branching usually indicates noise rather than a second independent leak.

Common mistakes

  • Reading a single performance.memory sample and concluding leak or no-leak; heap size oscillates with GC, so a one-time read is uninformative without a series of samples across the same transition.
  • Forcing GC from a script without a stable baseline; comparing a post-GC snapshot to a non-GC snapshot conflates GC reclamation with real retention loss.
  • Assuming WeakMap and WeakSet always release keys; they only release keys that are themselves unreachable, so any closure that retains the key keeps the entry reachable through the value.
  • Trusting 'Detached HTMLDivElement' counts in isolation; those nodes only matter if their retention root is reachable, which is what the Retainers panel reveals.
  • Adding new event listeners or intervals in the diagnostic step itself to 'probe' the system; this changes the heap and invalidates the comparison between the two snapshots.
  • Conflating a frame-rate regression with a memory regression; user-timing marks and Performance Monitor frames are independent signals that must be checked separately before either is acted on.

Safe fixes

  • If a route's component subscribes to a module-level store and the retainer path shows the store as the root, change the subscription to be tied to the component lifecycle and add a matching unsubscribe in the unmount hook; gate the change behind a flag and re-run the snapshot pair to verify the retainer class shrinks.
  • If a setInterval or requestAnimationFrame loop is retained by a closure from a route, return the handle to a module-local variable and clear it in the unmount hook; verify by deleting timers in the second snapshot and checking that the closure count for that source file returns to its baseline.
  • If a cache map grows without bound, introduce size-capped eviction or move to WeakRef / FinalizationRegistry semantics, and confirm by re-running the snapshot pair that the retained size of the cache no longer climbs across the same N round-trips.
  • If a transition wrapper retains the previous route tree, shorten the retention window so that the wrapper releases its references as soon as the transition completes; verify the retainer path to the previous tree is severed in Heap 1.
  • If a third-party widget attaches via script load, wrap its mount in a route-scoped component that calls its destroy or dispose API on unmount; verify by checking the listener count on window before and after the route sequence.
  • If a DOM reference is captured in a scroll/resize handler, replace direct node references with a WeakRef or scoped re-query inside the handler so the node is releasable when unmounted; verify by retaining the same node after unmount and observing it becomes unreachable in the next snapshot.

Prove the fix

  1. 01Repeat the original N-round-trip navigation sequence; performance.memory.usedJSHeapSize after the final transition is within an envelope defined as: baseline (Heap 0 usedJSHeapSize) plus a fixed allowance for first-load caches, with no upward step per round-trip.
  2. 02A new snapshot pair (Heap 2, Heap 3) shows the suspect retainer class at its baseline count rather than at Heap 1's elevated count, with the retainer path no longer naming the fixed lifecycle hook.
  3. 03Window and document listener counts after the sequence equal their counts before the sequence, measured through the same instrumentation used during diagnosis.
  4. 04User-timing marks for the post-navigation render remain unchanged across the sequence, confirming the fix does not regress render time while it removes retention.
  5. 05Two consecutive identical sessions, each running the full sequence, produce overlapping heap trajectories within the defined allowance; a regression reintroduces the monotonic step.

Prevention and next steps

  • Adopt a route lifecycle contract that pairs every subscribe / setInterval / addEventListener with a matching unsubscribe / clear / remove in the same file, enforced through code review or lint rules.
  • Wrap query caches and feature stores with size caps and explicit eviction hooks, and prefer WeakRef for nodes whose lifetime should follow the route, so transitively retained state cannot outlive its route by construction.
  • Keep a thin diagnostic harness that exposes performance.memory sampling and listener counting behind a flag, so future navigations can be checked without rebuilding the app and without altering the production heap.
  • Periodically run a navigation soak (e.g., 50 route round-trips) in CI and compare the heap envelope to a stored baseline, failing the build when the trend exceeds an agreed allowance.

Safe commands and checks

performance.memory sample loop (illustrative): const series = []; for (let i = 0; i < N; i++) { history.pushState({}, '', '/route-' + i); await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r))); series.push({i, used: performance.memory.usedJSHeapSize, total: performance.memory.totalJSHeapSize}); } console.table(series); — read-only; replace '/route-' with the application's actual route pattern; collect only and do not act on the results mid-loop.
performance.mark / performance.measure boundary for navigation (illustrative): performance.mark('nav-start'); await import(/* route module */); performance.mark('nav-end'); performance.measure('nav', 'nav-start', 'nav-end'); — read-only; surfaces render timing as a check that retention is not conflated with frame regressions.
listener-count instrumentation snippet (illustrative, dev only): const counts = new WeakMap(); const origAdd = EventTarget.prototype.addEventListener; EventTarget.prototype.addEventListener = function (t, h, o) { const k = this === window ? 'window' : this === document ? 'document' : 'el'; counts.set(k, (counts.get(k) || 0) + 1); return origAdd.call(this, t, h, o); }; — read-only instrumentation for diagnosis; remove or branch under a build flag before shipping.
snapshot comparison hook (illustrative): take Heap 0, perform N identical round-trips, force GC, take Heap 1, then in the comparison view sort by retained size descending and expand the top retainer's path to its GC root; do not script the comparison itself, treat it as a manual readout.
target selector placeholder for the suspect file (illustrative): in the source map of the retainer path, locate the file at <source-file>:<line> and open it; replace <source-file> and <line> with the values reported by the snapshot's retainer view.