Introduction
JavaScript memory leaks in single-page applications (SPAs) occur when applications accumulate unused memory over time, causing performance degradation, UI lag, and eventual browser tab crashes. Unlike server-side applications that restart per request, SPAs run continuously in the browser for extended sessions, making memory management critical. Memory leaks happen when objects are retained in memory longer than needed because JavaScript's garbage collector cannot reclaim them due to lingering references. Common causes include event listeners not removed on component unmount, closures capturing outer scope variables indefinitely, detached DOM trees held by JavaScript references, global variables accumulating data, setInterval/setTimeout callbacks not cleared, third-party libraries holding references, React useEffect hooks without cleanup functions, Vue watchers not unregistered, Angular subscriptions not unsubscribed, and circular references between objects. The fix requires understanding JavaScript's garbage collection algorithm, heap snapshot analysis, retention paths, and framework-specific lifecycle management. This guide provides production-proven debugging patterns for React, Vue, Angular, and vanilla JavaScript applications.
Symptoms
- Application becomes progressively slower over time
- Browser tab consumes increasing RAM (visible in Task Manager)
- UI interactions lag after extended use
- "Aw, Snap!" crash in Chrome due to memory exhaustion
- DevTools Performance panel shows increasing heap size
- Garbage collection takes longer between cycles
- Memory never returns to baseline after navigation
- Detached DOM trees visible in heap snapshots
- Component instances retained after unmount
- Event listeners accumulating on global objects
Common Causes
- Event listeners not removed on cleanup
- Closures capturing and retaining large objects
- Detached DOM trees with JavaScript references
- Global variables or caches growing unbounded
- setInterval/setTimeout not cleared
- Third-party library instances not destroyed
- React useEffect without cleanup function
- Vue watchers not unregistered
- Angular Observable subscriptions not unsubscribed
- Circular references preventing GC
Step-by-Step Fix
### 1. Diagnose memory leaks
Identify memory growth pattern:
```javascript // Open Chrome DevTools Console // Monitor memory usage over time
// Take heap snapshot // Console > Memory > Take snapshot
// Or programmatically check memory performance.memory && { usedJSHeapSize: performance.memory.usedJSHeapSize, totalJSHeapSize: performance.memory.totalJSHeapSize, jsHeapSizeLimit: performance.memory.jsHeapSizeLimit }
// Force garbage collection (requires DevTools open) // In Console: click garbage can icon in Memory panel
// Monitor trend across multiple navigations: // 1. Navigate to component // 2. Take heap snapshot // 3. Navigate away // 4. Force GC // 5. Take another snapshot // 6. Compare - memory should return to baseline ```
Use Chrome DevTools Memory panel:
```javascript // Chrome DevTools > Memory tab
// Three profiling types:
// 1. Heap Snapshot - Object allocation at point in time // Best for: Finding retained objects, detached DOM trees
// 2. Allocation timeline - Track allocations over time // Best for: Identifying when leak occurs, what code allocates
// 3. Allocation sampling - Sample-based allocation profile // Best for: Quick identification of memory-heavy code
// Workflow for heap snapshot comparison: // 1. Take snapshot (Snapshot 1) // 2. Perform action suspected of leaking (e.g., navigate to component) // 3. Take snapshot (Snapshot 2) // 4. Perform reverse action (e.g., navigate away) // 5. Force garbage collection // 6. Take snapshot (Snapshot 3) // 7. Compare Snapshot 1 vs Snapshot 3 // - Objects in Snapshot 3 not in Snapshot 1 = leak candidates ```
Analyze retention paths:
```javascript // In Heap Snapshot, switch to "Summary" view
// Look for: // - (Detached DOM tree) - DOM nodes removed but still referenced // - (Closure) - Functions capturing outer scope // - Array, Object - Growing collections
// Click on object to see "Retainers" panel: // Shows chain of references keeping object alive
// Common retention patterns: // Window > EventListener > Component // Window > globalCache > data // Closure > outerVariable > largeObject
// Filter by constructor name to find specific leaks: // - Type filter: "HTMLDivElement" for DOM leaks // - Type filter: "Array" for growing collections // - Type filter: "Closure" for function captures ```
### 2. Fix event listener memory leaks
Remove event listeners on cleanup:
```javascript // WRONG: Listener added but never removed function MyComponent() { useEffect(() => { const handler = (e) => console.log(e); window.addEventListener('resize', handler); // Missing: removeEventListener on cleanup }, []); }
// CORRECT: Clean up listener on unmount function MyComponent() { useEffect(() => { const handler = (e) => console.log(e); window.addEventListener('resize', handler);
return () => { window.removeEventListener('resize', handler); }; }, []); }
// Vanilla JavaScript cleanup class Component { constructor() { this.handler = this.handler.bind(this); window.addEventListener('resize', this.handler); }
handler(e) { console.log(e); }
destroy() { // Must remove with same reference window.removeEventListener('resize', this.handler); } } ```
Use AbortController for cleanup:
```javascript // Modern pattern with AbortController function MyComponent() { useEffect(() => { const controller = new AbortController();
window.addEventListener('resize', (e) => { console.log('Resized:', e); }, { signal: controller.signal });
document.addEventListener('scroll', (e) => { console.log('Scrolled:', e); }, { signal: controller.signal });
// Single abort cleans up all listeners return () => controller.abort(); }, []); }
// Works with fetch too useEffect(() => { const controller = new AbortController();
fetch('/api/data', { signal: controller.signal }) .then(res => res.json()) .catch(err => { if (err.name === 'AbortError') return; console.error(err); });
return () => controller.abort(); }, []); ```
Clean up third-party library listeners:
```javascript // Chart.js example useEffect(() => { const chart = new Chart(ctx, config);
return () => { chart.destroy(); // Removes internal listeners }; }, []);
// Google Maps example useEffect(() => { const map = new google.maps.Map(element, options); const marker = new google.maps.Marker({ map, position });
const clickListener = marker.addListener('click', onClick);
return () => { clickListener.remove(); // Remove specific listener google.maps.event.clearInstanceListeners(map); // Or clear all }; }, []);
// Mapbox GL example useEffect(() => { const map = new mapboxgl.Map(options);
return () => { map.remove(); // Cleans up all listeners }; }, []); ```
### 3. Fix closure memory leaks
Avoid capturing large objects in closures:
```javascript // WRONG: Closure captures entire largeObject function createProcessor() { const largeObject = new Array(1000000).fill('data');
return function process() { // Only needs count, but captures all of largeObject console.log(largeObject.length); }; }
// CORRECT: Capture only what's needed function createProcessor() { const largeObject = new Array(1000000).fill('data'); const count = largeObject.length;
return function process() { console.log(count); // Only captures count primitive }; }
// Or nullify reference when done function createProcessor() { let largeObject = new Array(1000000).fill('data');
const process = function() { console.log(largeObject.length); };
// After initialization, release reference const init = () => { process(); largeObject = null; // Allow GC };
return { init, process }; } ```
Break closure cycles with WeakMap:
```javascript // WRONG: Strong reference cycle const cache = new Map();
function processData(data) { if (cache.has(data)) { return cache.get(data); }
const result = heavyComputation(data); cache.set(data, result); // data keeps result alive return result; }
// CORRECT: WeakMap allows GC of keys const cache = new WeakMap();
function processData(data) { if (cache.has(data)) { return cache.get(data); }
const result = heavyComputation(data); cache.set(data, result); // Weak reference to data return result; }
// When data object is no longer referenced elsewhere, // both data and its cached result can be GC'd ```
Use WeakRef for object references:
```javascript // Keep reference to object without preventing GC class Cache { constructor() { this.cache = new Map(); }
set(key, value) { // WeakRef allows value to be GC'd if no other references this.cache.set(key, new WeakRef(value)); }
get(key) { const ref = this.cache.get(key); if (!ref) return undefined;
// Dereference - may return undefined if GC'd const value = ref.deref();
if (value === undefined) { this.cache.delete(key); // Clean up dead reference }
return value; } }
// FinalizationRegistry for cleanup callbacks const registry = new FinalizationRegistry((heldValue) => { console.log('Object garbage collected:', heldValue); cleanup(heldValue); });
const obj = { data: 'large' }; registry.register(obj, 'unique-id');
// When obj is GC'd, callback runs with 'unique-id' ```
### 4. Fix detached DOM tree leaks
Remove DOM references on cleanup:
```javascript // WRONG: Reference to removed DOM element function MyComponent() { const elementRef = useRef(null); const [data, setData] = useState(null);
useEffect(() => { const el = elementRef.current;
// Store reference for later use window.storedElement = el;
// Remove from DOM el.remove();
// window.storedElement now holds detached DOM tree! }, []);
return <div ref={elementRef}>Content</div>; }
// CORRECT: Clear references when removing from DOM function MyComponent() { const elementRef = useRef(null);
useEffect(() => { const el = elementRef.current;
// Use element, then clean up const observer = new MutationObserver(callback); observer.observe(el, { childList: true });
return () => { observer.disconnect(); // Clear any stored references window.storedElement = null; }; }, []);
return <div ref={elementRef}>Content</div>; } ```
Break parent-child reference chains:
```javascript // Detached DOM tree pattern // Parent removed from DOM but child still referenced
// WRONG function Component() { const childRef = useRef(null);
useEffect(() => { // Store child reference globally window.childNode = childRef.current; }, []);
// When parent is unmounted, child is detached but window.childNode // keeps entire tree alive (child > parent > all siblings)
return ( <div id="parent"> <div ref={childRef}>Child</div> </div> ); }
// CORRECT: Don't store DOM references outside component function Component() { useEffect(() => { // Use DOM within component lifecycle return () => { // Clean up on unmount }; }, []);
return <div>Content</div>; }
// If external reference needed, store data not DOM node function Component() { const [value, setValue] = useState('');
useEffect(() => { window.componentValue = value; // Store primitive, not DOM return () => { window.componentValue = null; }; }, [value]);
return <div>{value}</div>; } ```
Clean up MutationObserver and ResizeObserver:
```javascript // WRONG: Observer not disconnected useEffect(() => { const observer = new MutationObserver((mutations) => { console.log(mutations); });
observer.observe(element, { childList: true });
// Missing: observer.disconnect() on cleanup }, []);
// CORRECT useEffect(() => { const observer = new MutationObserver((mutations) => { console.log(mutations); });
observer.observe(element, { childList: true });
return () => { observer.disconnect(); }; }, []);
// ResizeObserver same pattern useEffect(() => { const resizeObserver = new ResizeObserver((entries) => { console.log(entries); });
resizeObserver.observe(element);
return () => { resizeObserver.disconnect(); resizeObserver.unobserve(element); }; }, []); ```
### 5. Fix timer memory leaks
Clear intervals and timeouts:
```javascript // WRONG: Interval never cleared function MyComponent() { useEffect(() => { const id = setInterval(() => { console.log('tick'); }, 1000);
// Missing: clearInterval(id) }, []);
return <div>Component</div>; }
// CORRECT: Clear on cleanup function MyComponent() { useEffect(() => { const id = setInterval(() => { console.log('tick'); }, 1000);
return () => { clearInterval(id); }; }, []);
return <div>Component</div>; }
// Multiple timers - track all IDs useEffect(() => { const intervalIds = [];
const id1 = setInterval(() => console.log('timer1')); const id2 = setInterval(() => console.log('timer2')); intervalIds.push(id1, id2);
const timeoutId = setTimeout(() => console.log('timeout'), 5000); intervalIds.push(timeoutId);
return () => { intervalIds.forEach(id => clearInterval(id)); }; }, []); ```
Handle async operations in timers:
```javascript // Async interval with proper cleanup function MyComponent() { useEffect(() => { let isMounted = true; let intervalId;
const tick = async () => { try { const data = await fetchData(); if (isMounted) { setData(data); } } catch (error) { if (isMounted) { console.error(error); } } };
intervalId = setInterval(tick, 1000);
return () => { isMounted = false; // Prevent state updates after unmount clearInterval(intervalId); }; }, []);
return <div>Component</div>; }
// Or use AbortController for fetch cancellation useEffect(() => { const controller = new AbortController();
const tick = async () => { try { const data = await fetchData({ signal: controller.signal }); setData(data); } catch (error) { if (error.name !== 'AbortError') { console.error(error); } } };
const intervalId = setInterval(tick, 1000);
return () => { controller.abort(); clearInterval(intervalId); }; }, []); ```
### 6. Fix React-specific memory leaks
Clean up useEffect properly:
```javascript // Event listener cleanup useEffect(() => { const handleResize = () => setWidth(window.innerWidth); window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize); }, []);
// Async operation cleanup useEffect(() => { let cancelled = false;
async function load() { const result = await api.fetch(); if (!cancelled) setResult(result); }
load();
return () => { cancelled = true; }; }, []);
// Subscription cleanup useEffect(() => { const subscription = store.subscribe(handleUpdate);
return () => subscription.unsubscribe(); }, []);
// WebSocket cleanup useEffect(() => { const ws = new WebSocket('ws://localhost:8080');
ws.onmessage = handleMessage; ws.onerror = handleError;
return () => { ws.close(); ws.onmessage = null; ws.onerror = null; }; }, []); ```
Fix useCallback and useMemo retention:
```javascript // WRONG: Callback recreated every render, holds stale closure function Component({ data }) { const [count, setCount] = useState(0);
// This callback captures 'data' but is never recreated // If 'data' is large, it's retained even when not needed const handleClick = useCallback(() => { console.log(data); }, []); // Missing 'data' dependency
return <button onClick={handleClick}>Click</button>; }
// CORRECT: Proper dependency array function Component({ data }) { const handleClick = useCallback(() => { console.log(data); }, [data]); // Recreate when data changes
return <button onClick={handleClick}>Click</button>; }
// Or if callback shouldn't change, use ref for latest value function Component({ data }) { const dataRef = useRef(data); dataRef.current = data;
const handleClick = useCallback(() => { console.log(dataRef.current); }, []);
return <button onClick={handleClick}>Click</button>; } ```
Clean up portals and refs:
```javascript // Portal cleanup function Modal({ children }) { const modalRoot = document.getElementById('modal-root');
useEffect(() => { // Portal content appended return () => { // Clean up on unmount const modal = document.getElementById('modal'); if (modal && modal.parentNode) { modal.parentNode.removeChild(modal); } }; }, []);
return createPortal(children, modalRoot); }
// Ref cleanup for external libraries function MapComponent() { const mapRef = useRef(null); const containerRef = useRef(null);
useEffect(() => { mapRef.current = new MapLibreGL.Map({ container: containerRef.current, // ... options });
return () => { if (mapRef.current) { mapRef.current.remove(); // Clean up map instance mapRef.current = null; } }; }, []);
return <div ref={containerRef} />; } ```
### 7. Fix Vue-specific memory leaks
Clean up watchers and computed:
```javascript // Vue 3 Composition API export default { setup() { const count = ref(0);
// Watcher needs cleanup const stopWatch = watch(count, (newVal) => { console.log('Count changed:', newVal); });
// Computed can be stopped const doubled = computed(() => count.value * 2);
onMounted(() => { // Event listener window.addEventListener('resize', handleResize); });
onUnmounted(() => { // Clean up watcher stopWatch();
// Clean up event listener window.removeEventListener('resize', handleResize); }); } };
// Vue 2 Options API export default { data() { return { count: 0 }; },
created() { this.$watch('count', function(newVal) { console.log('Count:', newVal); });
// Custom event bus EventBus.$on('custom-event', this.handler); },
beforeDestroy() { // Vue 2 automatically cleans up $watch // But custom event listeners need manual cleanup EventBus.$off('custom-event', this.handler); } }; ```
Clean up Vuex subscriptions:
```javascript // Vuex store subscription leak export default { mounted() { // Store subscription this.unsubscribe = this.$store.subscribe((mutation, state) => { console.log(mutation.type); }); },
beforeDestroy() { // Must unsubscribe if (this.unsubscribe) { this.unsubscribe(); } } };
// Vue 3 with setup setup() { const store = useStore();
const unsubscribe = store.subscribe((mutation) => { console.log(mutation); });
onUnmounted(() => { unsubscribe(); });
return {}; } ```
### 8. Fix Angular-specific memory leaks
Unsubscribe from Observables:
```typescript // WRONG: Subscription not cleaned up @Component({...}) export class MyComponent implements OnInit { data: any;
constructor(private service: DataService) {}
ngOnInit() { this.service.getData().subscribe(data => { this.data = data; }); // Missing: unsubscribe } }
// CORRECT: Use Subscription container @Component({...}) export class MyComponent implements OnInit, OnDestroy { data: any; private destroy$ = new Subject<void>();
constructor(private service: DataService) {}
ngOnInit() { this.service.getData() .pipe(takeUntil(this.destroy$)) .subscribe(data => { this.data = data; }); }
ngOnDestroy() { this.destroy$.next(); this.destroy$.complete(); } }
// Or use AsyncPipe in template (auto-cleanup) @Component({...}) export class MyComponent { data$: Observable<any>;
constructor(private service: DataService) { this.data$ = this.service.getData(); } } // Template: <div *ngIf="data$ | async as data">{{ data }}</div> ```
Clean up event listeners and timers:
```typescript @Component({...}) export class MyComponent implements OnInit, OnDestroy { private resizeHandler: () => void; private intervalId: any;
ngOnInit() { // Event listener this.resizeHandler = () => this.onResize(); window.addEventListener('resize', this.resizeHandler);
// Timer this.intervalId = setInterval(() => { this.tick(); }, 1000); }
ngOnDestroy() { // Clean up event listener window.removeEventListener('resize', this.resizeHandler);
// Clean up timer if (this.intervalId) { clearInterval(this.intervalId); } }
onResize() { console.log('Resized'); }
tick() { console.log('Tick'); } }
// RxJS fromEvent with auto-cleanup ngOnInit() { fromEvent(window, 'resize') .pipe( debounceTime(300), takeUntil(this.destroy$) ) .subscribe(() => this.onResize()); } ```
### 9. Prevent cache and global variable leaks
Implement cache size limits:
```javascript // WRONG: Unbounded cache grows forever const cache = new Map();
function getCached(key) { if (!cache.has(key)) { cache.set(key, expensiveOperation(key)); } return cache.get(key); } // Cache grows indefinitely - memory leak
// CORRECT: LRU cache with size limit class LRUCache { constructor(maxSize = 100) { this.maxSize = maxSize; this.cache = new Map(); }
get(key) { if (!this.cache.has(key)) return undefined;
// Move to end (most recently used) const value = this.cache.get(key); this.cache.delete(key); this.cache.set(key, value);
return value; }
set(key, value) { if (this.cache.has(key)) { this.cache.delete(key); } else if (this.cache.size >= this.maxSize) { // Remove oldest (first) item const firstKey = this.cache.keys().next().value; this.cache.delete(firstKey); } this.cache.set(key, value); }
clear() { this.cache.clear(); } }
const cache = new LRUCache(100); ```
Clean up global variables:
```javascript // Avoid polluting global scope // WRONG window.tempData = []; window.processing = false; window.results = {};
// These accumulate and never get cleaned up
// CORRECT: Use module pattern or cleanup class DataManager { constructor() { this.tempData = []; this.processing = false; this.results = {}; }
destroy() { this.tempData = null; this.processing = false; this.results = null; } }
// Or use WeakMap for instance-specific data const instanceData = new WeakMap();
class Component { constructor() { instanceData.set(this, { tempData: [], results: {} }); }
destroy() { instanceData.delete(this); // Allow GC } } ```
### 10. Monitor memory in production
Add memory monitoring:
```javascript // Memory monitoring utility class MemoryMonitor { constructor(thresholdMB = 500) { this.threshold = thresholdMB * 1024 * 1024; this.samples = []; }
sample() { if (performance.memory) { const used = performance.memory.usedJSHeapSize; this.samples.push({ time: Date.now(), used, total: performance.memory.totalJSHeapSize, limit: performance.memory.jsHeapSizeLimit });
// Keep last 60 samples (1 minute at 1 sample/sec) if (this.samples.length > 60) { this.samples.shift(); }
// Alert if consistently growing if (this.samples.length >= 10) { const trend = this.calculateTrend(); if (trend > 0 && used > this.threshold) { console.warn('Memory leak detected:', { usedMB: Math.round(used / 1024 / 1024), trendMBPerSec: (trend / 1024 / 1024).toFixed(2) }); } } } }
calculateTrend() { const first = this.samples[0].used; const last = this.samples[this.samples.length - 1].used; const timeDiff = (this.samples[this.samples.length - 1].time - this.samples[0].time) / 1000; return (last - first) / timeDiff; // bytes per second }
start() { this.interval = setInterval(() => this.sample(), 1000); }
stop() { clearInterval(this.interval); }
getReport() { if (this.samples.length === 0) return null;
const latest = this.samples[this.samples.length - 1]; const trend = this.calculateTrend();
return { usedMB: Math.round(latest.used / 1024 / 1024), totalMB: Math.round(latest.total / 1024 / 1024), trendMBPerSec: (trend / 1024 / 1024).toFixed(3), isLeaking: trend > 1024 * 1024 // More than 1MB/s growth }; } }
// Usage const monitor = new MemoryMonitor(300); // Alert at 300MB monitor.start();
// Get report anytime console.log(monitor.getReport()); ```
Use Performance API for metrics:
```javascript // Report memory metrics to analytics function reportMemoryMetrics() { if (!performance.memory) return;
const metrics = { memoryUsed: performance.memory.usedJSHeapSize, memoryTotal: performance.memory.totalJSHeapSize, memoryLimit: performance.memory.jsHeapSizeLimit, domNodes: document.getElementsByTagName('*').length, eventListeners: getEventListenersCount(), timestamp: Date.now() };
// Send to analytics (beacon API for reliability) navigator.sendBeacon('/api/memory-metrics', JSON.stringify(metrics)); }
// Count event listeners (debugging only) function getEventListenersCount() { let count = 0;
// Note: getEventListeners is Chrome DevTools only // For production, track listeners manually
const allElements = document.querySelectorAll('*'); allElements.forEach(el => { // Check for known listener patterns if (el.onclick) count++; if (el.onresize) count++; // etc. });
return count; }
// Report on page visibility change (user leaving) document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'hidden') { reportMemoryMetrics(); } }); ```
Prevention
- Always clean up subscriptions in component destroy hooks
- Use AbortController for cancellable async operations
- Implement LRU caches with size limits instead of unbounded Maps
- Avoid storing DOM references outside component lifecycle
- Use WeakMap/WeakSet for object associations
- Run memory profiling during development before releases
- Add memory monitoring to production dashboards
- Document cleanup requirements for custom hooks/utilities
Related Errors
- **Detached DOM tree**: DOM removed but JavaScript reference retained
- **Closure retaining object**: Function scope keeps object alive
- **Subscription memory leak**: Observable not unsubscribed
- **Event listener accumulation**: Listeners added but not removed
- **Timer leak**: setInterval/setTimeout not cleared on unmount