Browser · advanced

Browser retained listeners: trace the cleanup path that keeps nodes alive

Browser retained listeners are the dominant cause of detached-DOM retention in modern single-page apps. This guide walks through identifying the cleanup path, instrumenting Performance and Memory APIs, and verifying that handlers and observers actually release their references when the owning UI disappears.

The symptoms

  • Heap snapshots grow steadily between user navigation events even after large components are removed from the visible tree.
  • Detached DOM tree nodes appear in Memory panel snapshots with non-zero retained size and reachable origins outside the detached tree.
  • Performance.measureUserAgentSpecificMemory() reports monotonically increasing `bytes` values across navigation boundaries.
  • GC pause frequency climbs while the user actively navigates away from and back to a feature surface.
  • Component-removal handlers never fire: registered callbacks bound to `useEffect`, `addEventListener`, or MutationObserver never log their teardown.

Likely causes

  • A subscription established in `componentDidMount` / `useEffect` is returned to a singleton store that holds a strong reference and never unregisters.
  • An `addEventListener` is registered on a long-lived ancestor (window, document, document.body) without a matching `removeEventListener` on unmount.
  • A `MutationObserver`, `IntersectionObserver`, `ResizeObserver`, or `EventSource` keeps a closure that captures the unmounted component's state.
  • A third-party SDK (analytics, chat, video, maps) attaches listeners or observers that the application cannot remove on unmount.
  • An AbortController exists but is never invoked, or its `signal` is never passed to listener registration.
  • Stale closures over `this` or props keep references to props that themselves hold DOM nodes or large data structures.

First ten minutes

  1. 01Reproduce the leak deterministically: navigate into a feature, perform a known action sequence, then navigate away and back several times while watching heap size.
  2. 02Take a baseline heap snapshot in DevTools Memory panel after the first entry, then a second snapshot after the same number of exits/entries.
  3. 03Filter the second snapshot for "Detached HTMLDivElement" (or the expected element type) and note the count delta versus baseline.
  4. 04Open the Performance recorder, capture a full navigation cycle, and inspect the Memory chart for stepwise growth tied to each navigation.
  5. 05Search the codebase for `addEventListener`, `removeEventListener`, `subscribe`, `unsubscribe`, `observe`, `disconnect`, and `abort()` to map registered handlers to their teardown counterparts.
  6. 06Grep for `AbortController`, `AbortSignal`, and any framework lifecycle hook used for cleanup (`useEffect` return, `ngOnDestroy`, `useEffectCleanup`, `onUnmounted`).

Evidence to collect

  • DevTools Memory panel: comparison view between two snapshots, showing counts and retained sizes of detached nodes and their retainer chain.
  • Performance panel recording with the Memory track enabled, time-aligned against navigation events.
  • Source map-resolved call stacks from the retainer chain's "Object allocated by" pane for each retained listener closure.
  • List of every listener/observer registration paired with the existence (or absence) of a matching removal in the corresponding lifecycle hook.
  • `Performance.measureUserAgentSpecificMemory()` reports across the same navigation cycle, with notes on whether the value returns to baseline.

Where to look

  • Component lifecycle files for views that mount and unmount repeatedly (modals, route components, virtualized list items, tab panes).
  • Custom hooks that wrap `addEventListener`, observers, or pub/sub stores — these often centralize the leak across many call sites.
  • Singleton modules: analytics, websocket, auth, feature flag, and toast/notification services that outlive any single component.
  • Third-party SDK initialization files and the components that mount them; check whether the SDK exposes a teardown function.
  • Renderer boundary code where portals, iframes, or web workers attach event handlers that survive parent unmount.

Diagnostic steps

  1. 01Confirm the leak is listener-driven rather than data-driven: take a snapshot after GC, then another snapshot after disabling user interactions and letting timers run; growth implies listeners or intervals.
  2. 02In the second snapshot's comparison view, expand a retained detached node and read the retainer path; if the chain terminates at an `EventListener`, `Observer`, or framework-specific subscriber, the listener hypothesis is confirmed.
  3. 03For each suspected registration site, attach a temporary `console.trace` (or framework devtools hook) inside the registration call and inside the matching cleanup; verify the cleanup executes on unmount.
  4. 04Replace the registration with an `AbortController`-based registration and invoke `controller.abort()` on unmount; if the leak disappears, the original path lacked a removal.
  5. 05Isolate third-party contribution by mounting the suspect component in a stripped page with only the SDK loaded; if the leak persists, attribute it to the SDK's internal listeners.
  6. 06Distinguish DOM retention from GC retention by inspecting the retainer chain for direct references to `Node`, `Element`, or component props objects versus references to plain data.

Common mistakes

  • Assuming `removeEventListener` with the same function reference always works — anonymous arrow functions allocated inside render produce a fresh reference each time and never match.
  • Trusting framework devtools warnings without verifying them against a heap snapshot; warnings indicate potential issues but are not proof of retention.
  • Calling `disconnect()` on an observer that was never the one actually `observe()`d, because a different observer instance was stored in a stale variable.
  • Equating "performance.memory.usedJSHeapSize grows" with a listener leak — heap size can grow from caches, large allocations, or image decodes without any listener retention.
  • Cleaning up only the listener on the owning element while leaving a listener on `window` or `document` registered by the same module.

Safe fixes

  • If a `removeEventListener` is missing, add a paired removal in the framework's unmount lifecycle using a stable, named function stored in a ref so identity is preserved across renders.
  • If `AbortController` is supported in the target browsers, route every `addEventListener`, `fetch`, and observer registration through `signal` and call `controller.abort()` on unmount; this is the single safest cleanup primitive.
  • If a third-party SDK owns the listener and exposes no teardown, isolate the SDK inside a portal that is fully removed on unmount, and verify via snapshot that the SDK's DOM subtree and listeners are not reachable.
  • If a singleton store holds a subscriber, introduce an unsubscribe handle returned at registration time and call it from the unmount lifecycle; do not rely on the store to garbage-collect itself.
  • If a closure over props is the root cause, restructure to read only primitive dependencies or move the listener into a hook that re-binds on dep changes with explicit cleanup.

Prove the fix

  1. 01Snapshot before and after the navigation cycle shows the detached-node count returns to the baseline within one tolerance band (for example, ±2 elements) rather than climbing each iteration.
  2. 02The retainer chain for any remaining detached nodes no longer terminates at a listener, observer, or subscriber object owned by the unmounted component.
  3. 03`Performance.measureUserAgentSpecificMemory()` across repeated navigation cycles stays within a bounded band rather than trending upward.
  4. 04The Performance panel's Memory track shows flat segments between navigation events instead of step increases after each unmount.
  5. 05A targeted unit test asserts that, after unmount, calling `getEventListeners` (where available) or iterating registered observers returns zero entries owned by the unmounted instance.

Prevention and next steps

  • Establish a lint rule (custom ESLint or framework plugin) that flags every `addEventListener`/`observe`/`subscribe` call without a paired teardown in the same scope or lifecycle hook.
  • Standardize on `AbortController` as the default cleanup mechanism for any new listener, observer, or fetch; require reviewers to confirm the `signal` is passed.
  • Add a CI job that runs a headless navigation scenario and asserts the heap snapshot's detached-node count remains under a threshold after N iterations.
  • Document each third-party SDK's teardown API (or absence thereof) so feature teams do not assume cleanup is automatic.
  • Periodically audit singleton services for subscriber registries whose size grows monotonically across the application's lifetime.

Safe commands and checks

// Capture two heap snapshots around a known navigation cycle in DevTools Memory panel, then use the comparison view to filter detached nodes by their retainer chain.
// In the Performance panel, enable the Memory track and record a full navigation cycle; use the recording to correlate allocation spikes with unmount events.
// Programmatically measure memory across cycles (run in the page console, not against a local server):
if ('measureUserAgentSpecificMemory' in performance) {
  performance.measureUserAgentSpecificMemory().then(result => {
    console.log('bytes:', result.bytes, 'breakdown:', result.breakdown);
  });
}
// Search the repository for registrations lacking paired teardown:
grep -RIn --include='*.ts' --include='*.tsx' --include='*.js' --include='*.jsx' -E 'addEventListener|\.observe\(|subscribe\(' src | head -n 200
// Search the repository for explicit cleanup sites and cross-check coverage:
grep -RIn --include='*.ts' --include='*.tsx' --include='*.js' --include='*.jsx' -E 'removeEventListener|\.disconnect\(\)|unsubscribe\(|abort\(\)' src | head -n 200
// List files referencing AbortController/Signal to gauge adoption:
grep -RIn --include='*.ts' --include='*.tsx' --include='*.js' --include='*.jsx' -E 'AbortController|AbortSignal' src | head -n 200