Prevent common memory leak patterns
rule · memory-leaks
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
- Verify the behavior in the browser after the code change, not only in static analysis.
- Inspect DevTools Network or Performance panels when the rule affects loading or execution order.
- Test the primary user flow and one edge case triggered by the changed script path.
- Confirm the code still behaves correctly when the feature is delayed, lazy-loaded, or fails.
- Navigate away or change inputs mid-request and confirm stale fetches are aborted rather than updating dead UI state.