Skip to main content
Beta: Front-End Checklist is currently in beta. Some issues are still being fixed. Thanks for your patience.

Prevent common memory leak patterns

Identify and avoid the most common JavaScript memory leak sources: forgotten event listeners, retained DOM references, closures holding large objects, and uncleared timers.

Utilities
Quick take
Typical fix time 25 min
  • Remove event listeners when the element or component is destroyed
  • Clear setInterval and setTimeout when they're no longer needed
  • Avoid storing DOM references in long-lived objects after elements are removed
  • Use WeakMap and WeakSet when storing metadata about objects you don't own
  • Use AbortController to cancel stale async requests when UI state changes
Why it matters: Memory leaks cause applications to consume increasing amounts of memory over time, eventually slowing the browser tab or crashing it. In single-page applications, where users don't reload the page, small leaks in components accumulate with each navigation and can make an app unusable after 30 minutes of use.

Rule Details

JavaScript's garbage collector frees memory for objects with no remaining references. Memory leaks occur when your code holds references to objects that are no longer needed but can't be collected.

Code Example

// ❌ Leaks: listener is never removed, holds a reference to the element
class SearchComponent {
  constructor() {
    this.input = document.getElementById('search')
    document.addEventListener('keydown', this.handleKeydown)
    // If SearchComponent is garbage collected, the listener remains
  }
 
  handleKeydown = (event) => {
    if (event.key === 'Escape') this.clear()
  }
}
 
// ✅ Good: always provide a cleanup path
class SearchComponent {
  constructor() {
    this.input = document.getElementById('search')
    this.handleKeydown = this.handleKeydown.bind(this)
    document.addEventListener('keydown', this.handleKeydown)
  }
 
  destroy() {
    document.removeEventListener('keydown', this.handleKeydown)
    this.input = null
  }
}

Why It Matters

Memory leaks cause applications to consume increasing amounts of memory over time, eventually slowing the browser tab or crashing it. In single-page applications, where users don't reload the page, small leaks in components accumulate with each navigation and can make an app unusable after 30 minutes of use.

Event Listener Cleanup

Event listeners are one of the most common leak sources because they keep both the handler and the referenced objects alive.

// ❌ Anonymous listener cannot be removed later
button.addEventListener('click', () => doSomething())
 
// ✅ Keep a stable reference
const handleClick = () => doSomething()
button.addEventListener('click', handleClick)
 
function cleanup() {
  button.removeEventListener('click', handleClick)
}
// ✅ AbortController removes a group of listeners at once
const controller = new AbortController()
 
window.addEventListener('scroll', onScroll, { signal: controller.signal })
window.addEventListener('resize', onResize, { signal: controller.signal })
 
function cleanup() {
  controller.abort()
}

Uncleared Timers

// ❌ Leaks: interval fires forever even after the component is gone
function startPolling(callback) {
  setInterval(callback, 5000)
}
 
// ✅ Good: store the ID and clear it on cleanup
function startPolling(callback) {
  const intervalId = setInterval(callback, 5000)
  return () => clearInterval(intervalId) // return cleanup function
}
 
// Usage
const stopPolling = startPolling(syncData)
// Later, when done:
stopPolling()

Cancel Stale Async Requests

Long-running fetches can hold closures, state setters, and response objects alive after a component has already unmounted or the user has moved on:

useEffect(() => {
  const controller = new AbortController()
 
  async function loadSearchResults() {
    const response = await fetch(`/api/search?q=${query}`, {
      signal: controller.signal,
    })
 
    const data = await response.json()
    setResults(data)
  }
 
  loadSearchResults().catch((error) => {
    if (error.name !== 'AbortError') {
      throw error
    }
  })
 
  return () => controller.abort()
}, [query])

Retained DOM References

// ❌ Leaks: detached DOM tree held in memory through a JS object
const cache = {}
function cachePanel(id) {
  cache[id] = document.getElementById(id)
  document.getElementById(id).remove() // removed from DOM but still in cache
}
 
// ✅ Good: use WeakMap so DOM nodes can be garbage collected
const cache = new WeakMap()
function cachePanel(element) {
  cache.set(element, { timestamp: Date.now() })
  element.remove() // WeakMap won't prevent GC
}

Closures Holding Large Data

// ❌ Leaks: the event listener closure holds a reference to largeData
function processLargeData() {
  const largeData = new Array(1_000_000).fill('x')
  const result = computeSummary(largeData)
 
  button.addEventListener('click', () => {
    // Only result is used, but largeData is captured in the closure
    displayResult(result)
  })
}
 
// ✅ Good: don't capture what you don't need
function processLargeData() {
  const largeData = new Array(1_000_000).fill('x')
  const result = computeSummary(largeData)
  // largeData goes out of scope and can be collected
 
  button.addEventListener('click', () => {
    displayResult(result) // Only result is captured
  })
}

React Component Cleanup

// ✅ Clean up subscriptions and timers in useEffect cleanup
useEffect(() => {
  const subscription = store.subscribe(handleChange)
  const timerId = setInterval(pollForUpdates, 10000)
 
  return () => {
    subscription.unsubscribe()
    clearInterval(timerId)
  }
}, [])

Framework-specific cleanup still belongs to the same rule:

<script setup>
import { onMounted, onUnmounted } from 'vue'
 
function handleScroll() {}
 
onMounted(() => window.addEventListener('scroll', handleScroll))
onUnmounted(() => window.removeEventListener('scroll', handleScroll))
</script>

Standards

  • Use MDN: JavaScript Guide as the standard for how this JavaScript pattern should behave in production, not just in a small local example.
  • Use web.dev: Learn JavaScript as the standard for how this JavaScript pattern should behave in production, not just in a small local example.

Verification

  1. Verify the behavior in the browser after the code change, not only in static analysis.
  2. Inspect DevTools Network or Performance panels when the rule affects loading or execution order.
  3. Test the primary user flow and one edge case triggered by the changed script path.
  4. Confirm the code still behaves correctly when the feature is delayed, lazy-loaded, or fails.
  5. Navigate away or change inputs mid-request and confirm stale fetches are aborted rather than updating dead UI state.

Use with AI

Copy these prompts to use with your AI assistant, or install the MCP server to use directly from Claude, Cursor, or Windsurf.

Check

Verify implementation

Analyze this code for memory leak patterns: uncleaned event listeners, retained DOM references after removal, closures holding large objects, and uncleared timers. Also flag fetches and async tasks that continue after the owning component or view is gone.

Fix

Auto-fix issues

Fix the memory leaks in this code by cleaning up event listeners, clearing timers, canceling stale async requests, and releasing DOM references when they're no longer needed.

Explain

Learn more

Explain the four most common JavaScript memory leak patterns and how to detect them with Chrome DevTools.

Review

Code review

Review scripts, client components, and browser execution paths related to Prevent common memory leak patterns. Flag exact imports, event handlers, runtime side effects, or blocking operations that violate the rule, and state how the change should be verified in the browser.

Sources

References used to support the guidance in this rule.

Further Reading

Tools and supplementary material for exploring the topic in more depth.

Chrome DevTools Memory paneldeveloper.chrome.comTool

Rules that often go hand-in-hand with this one.

Minimize costly DOM read/write operations

Batch DOM reads and writes separately to avoid layout thrashing — the performance problem caused by alternating between reading and writing layout properties.

JavaScript
Split large JavaScript bundles

Use dynamic imports and route-based code splitting to break large bundles into smaller chunks that load on demand, reducing initial page load time.

JavaScript
Use event delegation for dynamic content

Attach event listeners to stable parent elements rather than individual dynamic children to reduce memory usage and handle elements added to the DOM after page load.

JavaScript

Was this rule helpful?

Your feedback helps improve rule quality. This stays internal for now.

Loading feedback...
0 / 385