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.

Beginner
15–30 minutes per component
Impact: High
Related
Browser APIs:web-workers
Experiments:memory-vitals

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

DOM Nodes
↓ 95%
Memory
↓ 80%
FPS
↑ 3–5×
Initial Render
↓ 70%

Decision Matrix

Architectural decision guidance for different scenarios.

Scenario: Component attaches a listener to window or document on mount.

Recommended:Store function reference and remove in onUnmounted / useEffect cleanup
🔄 Alternatives:VueUse useEventListener, @vueuse/core useEventListener

⚖️ 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.

Recommended:Centralize in a shared composable or store (e.g., Pinia)
🔄 Alternatives:Each component managing its own listener

⚖️ 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 NOT To Use
  • 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.

Review my Vue table for virtualization opportunitiesCopy Prompt