Preventing Memory Leaks from Event Listeners
How to safely add and remove event listeners in Vue and React components to prevent memory leaks that accumulate over the lifetime of a single-page application.
The Problem
In single-page applications, components mount and unmount repeatedly. If a component attaches a global event listener (on window, document, or a shared emitter) but never removes it on unmount, the listener and its closure — including references to the entire component tree — persist in memory indefinitely. Over time, this causes memory to grow without bound.
Common Symptoms
- Browser tab memory usage grows steadily as users navigate the application.
- Chrome DevTools Memory panel shows increasing heap size across navigation cycles.
- Taking a heap snapshot reveals many detached DOM nodes or closures.
- Application becomes sluggish after extended use without a page refresh.
- The same callback fires multiple times (once per mount) after navigating to a page repeatedly.
Root Causes
- addEventListener on global objects (window, document) holds a reference to the callback closure.
- The closure captures variables from the component scope, preventing garbage collection of the entire component.
- Vue/React's unmount lifecycle is not used to clean up the listener.
- Anonymous functions passed to addEventListener cannot be removed because removeEventListener requires the exact same function reference.
Typical Expected Improvements
Decision Matrix
Architectural decision guidance for different scenarios.
Scenario: Component attaches a listener to window or document on mount.
⚖️ Trade-offs: Manual cleanup requires discipline and is easy to forget. Using a composable automates it.
💡 Why: The listener must be removed with the exact same function reference used to add it. Storing it in a variable enables this.
Scenario: Many components use window.addEventListener for the same event type.
⚖️ Trade-offs: Centralization adds a layer of abstraction but prevents duplicate listeners accumulating.
💡 Why: Attaching N listeners for the same event (one per component instance) multiplies the processing cost per event.
- When the listener is attached to a component's own root element — Vue and React clean these up automatically on unmount.
- When using a framework-managed event system (e.g., Vue's `@click` template syntax) — these are handled by the framework.
- When the listener is intentionally global and persistent (e.g., a keyboard shortcut at the app level).
Approaches
Do This
- Store the listener function in a variable so it can be passed to removeEventListener.
- In Vue: use onUnmounted() to call removeEventListener with the stored reference.
- In React: return a cleanup function from useEffect that calls removeEventListener.
- Use @vueuse/core useEventListener composable — it handles cleanup automatically.
- Use AbortController to remove multiple listeners at once.
Anti-patterns
- Passing an anonymous function to addEventListener — you cannot remove an anonymous function later.
- Using onBeforeUnmount instead of onUnmounted to clean up — the DOM still exists in onBeforeUnmount, but the cleanup may be skipped if the component errors.
- Attaching listeners in the template's `mounted` lifecycle without a corresponding `beforeUnmount`/`unmounted` cleanup.
Implementation
import { useVirtualList } from '@vueuse/core' const { list, containerProps, wrapperProps } = useVirtualList( hugeDataArray, { itemHeight: 48 } )
Use AI
Instantly analyze your codebase using this recipe via MCP. Open your AI agent and run this prompt.