Browser · beginner

How to prove browser event listeners are removed

Proving that browser event listeners are actually removed after unmount requires observable evidence, not assumptions. This verification guide shows how to detect retained listeners from repeated mount/unmount cycles using framework-agnostic tooling and the browser Performance APIs.

The symptoms

  • Active listener count on a target stays the same after a component or module is unmounted, even though DOM nodes are detached.
  • Heap snapshots taken before and after repeated mount/unmount cycles show a growing number of closure scopes tied to removed nodes.
  • The number of PerformanceEventTiming entries for a given target type keeps rising across navigation-less user interactions.
  • Memory usage in the devtools Performance monitor trends upward when the same view is repeatedly mounted, opened, and closed.
  • Event handlers still fire on document or window even after the subscribing widget is removed from the DOM.

Likely causes

  • A framework render function attaches document, window, or body listeners but never calls the returned cleanup function on unmount.
  • An AbortController is created for cleanup, but the controller is recreated on every render so the previous controller never aborts the original subscription.
  • A { once: true } option was used under the assumption that it also removes the listener on its own target unmount; it does not.
  • A listener is registered on a singleton object (window, document, document.body) that outlives the component instance, so removal requires an explicit removeEventListener call.
  • A class field arrow function is passed as the handler, so addEventListener and removeEventListener receive different function references and the removal is a silent no-op.
  • Passive listeners on scroll or touchmove were registered without a matching reference and cannot be removed later.

First ten minutes

  1. 01Open the suspect page in a browser with devtools and reproduce the mount/unmount cycle, for example by navigating into and out of the view or by toggling a route.
  2. 02Open the Performance Monitor and observe JS heap size and DOM node count during the cycle; a rising heap with stable node count suggests retained listeners.
  3. 03Open the Elements panel, select the parent that hosts the listener target, and inspect the Event Listeners panel to enumerate listeners bound to it.
  4. 04Take a heap snapshot labeled before, perform five mount/unmount cycles, then take a snapshot labeled after and compare the two in the Heap Snapshot comparison view.
  5. 05Record a short performance profile with the JS profiler enabled, then inspect the timeline for repeated function references after the component is gone.
  6. 06Read the source of the mount path and identify every addEventListener call that is not paired with a matching removeEventListener in the unmount path.

Evidence to collect

  • The exact addEventListener calls in the mount path, including target, type, options object, and the handler function reference.
  • The matching removeEventListener calls in the unmount path, with the same target, type, options flags, and an identity-equal handler reference.
  • Heap snapshots taken before and after a fixed number of mount/unmount cycles, filtered to Detached DOM trees and to closure scopes referencing the suspect target.
  • A count of PerformanceEventTiming entries grouped by target over a fixed interaction sequence, to confirm whether the listener is still firing.
  • Devtools Event Listeners panel output for the target element, with the framework node selected and ancestors expanded.
  • The Performance Monitor trace for JS heap size across the mount/unmount cycle.

Where to look

  • The boundary between component code and the DOM: lifecycle hooks such as useEffect, onMounted/onUnmounted, componentDidMount/componentWillUnmount, and any framework ref attach or detach callback.
  • The boundary between component code and global singletons: window, document, document.body, navigator, and the history object, because removal on these targets is the developer’s responsibility.
  • The boundary between the module that owns the subscription and the module that owns the cleanup, because drift between these two is the most common cause of asymmetry.
  • The boundary between arrow-function or bound handlers and the addEventListener signature, since reference identity determines whether removal is possible.
  • The closure scope captured by the handler, since captured variables can keep large objects alive even after the DOM is gone.

Diagnostic steps

  1. 01Enumerate all addEventListener calls reachable from the mount path and classify each by target lifetime: per-instance DOM nodes, framework-managed nodes, or global singletons.
  2. 02For each call, search the unmount path for an exactly matching removeEventListener, where match means same target, same type, same capture/passive flag set, and an identity-equal handler reference.
  3. 03Confirm handler reference identity by reading the source: a top-level named function or a stable method passes the identity test, while inline arrow functions or bind results fail it.
  4. 04Take a baseline heap snapshot, run a controlled sequence of mount and unmount operations, and take a second snapshot; use the comparison view to count retained closures per snapshot.
  5. 05Use performance.eventCounts or PerformanceObserver with type: 'event' to count events delivered to the suspect target before and after unmount; a non-zero count after unmount indicates retention.
  6. 06Reproduce in a stripped page that registers exactly one listener on a known target, performs the suspect cycle, and uses getEventListeners on the target to read the listener list directly.
  7. 07Cross-check the EvidenceEventTiming timeline for the target and the event type to confirm whether the handler executed after the unmount tick.

Common mistakes

  • Assuming that once: true removes a listener when the DOM node is removed; the listener is removed only when the event fires, not when the node is detached.
  • Removing a listener with a different options object than the one used to add it, for example adding with { capture: true } and removing with {}, which silently leaves the listener attached.
  • Using an arrow function or bound method as the handler and then trying to remove the listener with a fresh reference, which is a no-op because the function identity differs.
  • Cleaning up on component destroy but not on conditional hide, so listeners attached inside an if branch remain while the user thinks the view is gone.
  • Trusting that removing a child element removes listeners attached to document or window, since those targets outlive every child.
  • Reading the Event Listeners panel only on the removed node; listeners attached to ancestors or to window still fire and must be checked separately.

Safe fixes

  • Stabilize the handler reference by hoisting it to a top-level named function or a class method, so the same reference can be passed to both addEventListener and removeEventListener.
  • Wrap the subscription in a single function that returns a cleanup closure, and ensure the framework lifecycle calls that cleanup on unmount; verify by reading the diff of the mount and unmount paths.
  • Use AbortController once per mount and pass controller.signal to addEventListener, then call controller.abort() on unmount; do not create a new controller on every render.
  • Centralize subscriptions to window, document, and document.body in a dedicated module that owns both attach and detach and exposes a single teardown function.
  • Replace inline arrow handlers in JSX or templates with named handlers stored on the instance, so template re-renders do not produce new function identities.
  • When the target is a long-lived singleton, attach the listener once at module load and expose an explicit subscribe/unsubscribe API instead of relying on lifecycle hooks.

Prove the fix

  1. 01Heap snapshot comparison shows a flat number of retained closures for the suspect target after N mount/unmount cycles, where N is at least 20.
  2. 02performance.eventCounts or PerformanceObserver with type: 'event' shows zero additional entries for the suspect target after the unmount tick over a fixed interaction sequence.
  3. 03The devtools Event Listeners panel on the target shows the same listener count before and after the cycle, with no listener bound after unmount.
  4. 04Performance Monitor JS heap size remains within a bounded band across the cycle and does not trend upward across repeated cycles.
  5. 05A scripted getEventListeners check inside a minimal reproduction page returns an empty list for the target after the cleanup function runs.
  6. 06A code-level audit finds a matching removeEventListener for every addEventListener, with identical target, type, capture/passive flags, and identity-equal handler reference.

Prevention and next steps

  • Establish a code-review rule that every addEventListener on a global target must have a paired removeEventListener or AbortController.abort in the same module.
  • Add a heap snapshot regression check to the CI smoke suite that mounts and unmounts the top routes and fails if retained closures grow linearly with cycle count.
  • Favor named handlers and stable references in templates, so removal is mechanically possible rather than accidental.
  • Document which modules own window, document, and document.body listeners and require an explicit teardown function for each.
  • Audit every use of once: true and every passive: true registration to confirm that an unmount path exists that does not depend on the listener firing.

Safe commands and checks

// PerformanceObserver pattern to count events delivered to a target type after cleanup: const counts = new Map(); const obs = new PerformanceObserver((list) => { for (const entry of list.getEntries()) { const k = entry.name || '(unknown)'; counts.set(k, (counts.get(k) || 0) + 1); } }); obs.observe({ type: 'event', buffered: true }); // After unmount and a fixed interaction sequence: console.log(counts);
// In a minimal reproduction page, read the listener list directly: // select the target element in devtools, then in the console: getEventListeners($0)
// Heap snapshot comparison is driven from the Memory panel: // 1) take snapshot labeled "before" // 2) run N mount/unmount cycles // 3) take snapshot labeled "after" // 4) switch the "after" view to Comparison and group by constructor
// Scripted check that a cleanup helper removed every subscription: const target = document.getElementById('<target-id>'); const before = (target && target.eventListenersListSize) || 0; runCleanup(); const after = (target && target.eventListenersListSize) || 0; console.log({ before, after });