The Garbage Collector Doesn't Save You in React
The Garbage Collector Doesn't Save You in React
Memory leaks specific to React apps — event listeners, closures in useEffect, WebSocket connections, and how to audit, detect, and fix them in production.
The False Sense of Security
JavaScript has garbage collection. React unmounts components. Memory should be freed automatically, right?
Mostly. But React's patterns create specific scenarios where memory accumulates without being released. And because JavaScript's GC is non-deterministic, leaks often go unnoticed until users report that the app becomes slow after extended use.
THE MENTAL MODEL PROBLEM
────────────────────────────────────────────────────────────────────
What developers think:
┌─────────────────────────────────────────────────────────────────┐
│ Component mounts → uses memory → unmounts → memory freed │
└─────────────────────────────────────────────────────────────────┘
What actually happens:
┌─────────────────────────────────────────────────────────────────┐
│ Component mounts → creates references → unmounts │
│ │
│ But those references might: │
│ ├── Be held by event listeners on window/document │
│ ├── Be captured in closures that outlive the component │
│ ├── Be stored in module-level caches │
│ ├── Be referenced by timers that weren't cleared │
│ └── Be held by subscriptions that weren't cancelled │
│ │
│ GC can't free what's still reachable. │
└─────────────────────────────────────────────────────────────────┘
The Classic Leaks
Leak 1: Uncleared Event Listeners
// THE LEAK
function MouseTracker() {
const [position, setPosition] = useState({ x: 0, y: 0 });
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
setPosition({ x: e.clientX, y: e.clientY });
};
window.addEventListener('mousemove', handleMouseMove);
// ❌ No cleanup - listener persists after unmount
}, []);
return <div>Position: {position.x}, {position.y}</div>;
}
What happens:
1. Component mounts
2. Event listener added to window
3. handleMouseMove closure captures setPosition
4. setPosition references component's state updater
5. Component unmounts
6. Event listener STILL on window
7. Every mouse move calls setPosition on unmounted component
8. React warns: "Can't perform state update on unmounted component"
9. The closure + component internals stay in memory
// THE FIX
function MouseTracker() {
const [position, setPosition] = useState({ x: 0, y: 0 });
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
setPosition({ x: e.clientX, y: e.clientY });
};
window.addEventListener('mousemove', handleMouseMove);
// ✅ Cleanup removes listener
return () => {
window.removeEventListener('mousemove', handleMouseMove);
};
}, []);
return <div>Position: {position.x}, {position.y}</div>;
}
Leak 2: Uncleared Timers
// THE LEAK
function Countdown({ seconds }: { seconds: number }) {
const [remaining, setRemaining] = useState(seconds);
useEffect(() => {
const interval = setInterval(() => {
setRemaining((r) => r - 1);
}, 1000);
// ❌ No cleanup
}, []);
return <div>{remaining}s</div>;
}
This is particularly bad because:
- The interval runs forever
- Each tick tries to update unmounted component state
- Multiple mounts = multiple intervals stacking up
// THE FIX
function Countdown({ seconds }: { seconds: number }) {
const [remaining, setRemaining] = useState(seconds);
useEffect(() => {
const interval = setInterval(() => {
setRemaining((r) => {
if (r <= 0) {
clearInterval(interval);
return 0;
}
return r - 1;
});
}, 1000);
return () => clearInterval(interval);
}, []);
return <div>{remaining}s</div>;
}
Leak 3: Uncancelled Async Operations
// THE LEAK
function UserProfile({ userId }: { userId: string }) {
const [user, setUser] = useState<User | null>(null);
useEffect(() => {
async function fetchUser() {
const response = await fetch(`/api/users/${userId}`);
const data = await response.json();
setUser(data); // ❌ Might run after unmount
}
fetchUser();
}, [userId]);
return user ? <Profile user={user} /> : <Loading />;
}
The problem: if the component unmounts before fetch completes, setUser is called on an unmounted component.
// FIX 1: AbortController
function UserProfile({ userId }: { userId: string }) {
const [user, setUser] = useState<User | null>(null);
useEffect(() => {
const controller = new AbortController();
async function fetchUser() {
try {
const response = await fetch(`/api/users/${userId}`, {
signal: controller.signal,
});
const data = await response.json();
setUser(data);
} catch (error) {
if (error.name === 'AbortError') {
// Expected, component unmounted
return;
}
throw error;
}
}
fetchUser();
return () => controller.abort();
}, [userId]);
return user ? <Profile user={user} /> : <Loading />;
}
// FIX 2: Mounted flag (simpler but less efficient)
function UserProfile({ userId }: { userId: string }) {
const [user, setUser] = useState<User | null>(null);
useEffect(() => {
let isMounted = true;
async function fetchUser() {
const response = await fetch(`/api/users/${userId}`);
const data = await response.json();
if (isMounted) {
setUser(data);
}
}
fetchUser();
return () => {
isMounted = false;
};
}, [userId]);
return user ? <Profile user={user} /> : <Loading />;
}
Leak 4: WebSocket / Subscription Connections
// THE LEAK
function LivePrices({ symbols }: { symbols: string[] }) {
const [prices, setPrices] = useState<Record<string, number>>({});
useEffect(() => {
const ws = new WebSocket('wss://prices.example.com');
ws.onopen = () => {
ws.send(JSON.stringify({ subscribe: symbols }));
};
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
setPrices((prev) => ({ ...prev, [data.symbol]: data.price }));
};
// ❌ WebSocket stays open, keeps receiving messages
}, [symbols]);
return (/* render prices */);
}
WebSockets are especially problematic:
- They hold TCP connections open
- They receive data indefinitely
- Multiple mounts without cleanup = multiple connections
- Server resources wasted, memory grows
// THE FIX
function LivePrices({ symbols }: { symbols: string[] }) {
const [prices, setPrices] = useState<Record<string, number>>({});
useEffect(() => {
const ws = new WebSocket('wss://prices.example.com');
ws.onopen = () => {
ws.send(JSON.stringify({ subscribe: symbols }));
};
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
setPrices((prev) => ({ ...prev, [data.symbol]: data.price }));
};
ws.onerror = (error) => {
console.error('WebSocket error:', error);
};
return () => {
// Unsubscribe before closing
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ unsubscribe: symbols }));
}
ws.close();
};
}, [symbols]); // Note: changing symbols recreates connection
return (/* render prices */);
}
The Sneaky Leaks
These are harder to spot because they don't trigger React warnings.
Leak 5: Closures Capturing Component Scope
// THE LEAK
function ChatRoom({ roomId }: { roomId: string }) {
const [messages, setMessages] = useState<Message[]>([]);
useEffect(() => {
const connection = createChatConnection(roomId);
connection.on('message', (msg) => {
// This closure captures the entire component scope
// including setMessages, which references component internals
setMessages((prev) => [...prev, msg]);
});
connection.connect();
return () => connection.disconnect();
}, [roomId]);
// THE SNEAKY PART: Passing closures to long-lived objects
useEffect(() => {
// ❌ Analytics singleton holds reference to this closure forever
analytics.onPageView(() => {
console.log(`Viewing room ${roomId} with ${messages.length} messages`);
});
}, [roomId, messages.length]);
return (/* render */);
}
The analytics.onPageView callback captures roomId and messages.length. If analytics is a singleton that never cleans up callbacks, every component mount adds another callback.
// THE FIX
function ChatRoom({ roomId }: { roomId: string }) {
const [messages, setMessages] = useState<Message[]>([]);
useEffect(() => {
const handlePageView = () => {
console.log(`Viewing room ${roomId} with ${messages.length} messages`);
};
analytics.onPageView(handlePageView);
// Return cleanup function provided by analytics
return () => analytics.offPageView(handlePageView);
}, [roomId, messages.length]);
return (/* render */);
}
Leak 6: Module-Level Caches
// cache.ts
// ❌ Module-level cache that grows without bounds
const userCache = new Map<string, User>();
export function getCachedUser(id: string): User | undefined {
return userCache.get(id);
}
export function setCachedUser(user: User): void {
userCache.set(user.id, user);
}
// This cache NEVER shrinks. Every user ever loaded stays in memory.
// THE FIX: Use WeakMap when possible, or implement eviction
// Option 1: WeakMap (if keys can be objects)
const userCache = new WeakMap<{ id: string }, User>();
// Option 2: LRU Cache with size limit
import { LRUCache } from 'lru-cache';
const userCache = new LRUCache<string, User>({
max: 500, // Maximum 500 entries
ttl: 1000 * 60 * 5, // 5 minute TTL
});
// Option 3: Manual eviction
const userCache = new Map<string, { user: User; timestamp: number }>();
const MAX_CACHE_AGE = 5 * 60 * 1000; // 5 minutes
function getCachedUser(id: string): User | undefined {
const entry = userCache.get(id);
if (!entry) return undefined;
if (Date.now() - entry.timestamp > MAX_CACHE_AGE) {
userCache.delete(id);
return undefined;
}
return entry.user;
}
// Periodic cleanup
setInterval(() => {
const now = Date.now();
for (const [id, entry] of userCache) {
if (now - entry.timestamp > MAX_CACHE_AGE) {
userCache.delete(id);
}
}
}, 60 * 1000); // Every minute
Leak 7: Context and Provider Memory
// THE LEAK
const DataContext = createContext<{
data: Record<string, any>;
setData: (key: string, value: any) => void;
} | null>(null);
function DataProvider({ children }: { children: React.ReactNode }) {
const [data, setDataState] = useState<Record<string, any>>({});
const setData = useCallback((key: string, value: any) => {
setDataState((prev) => ({ ...prev, [key]: value }));
}, []);
// ❌ Data object grows forever - nothing ever removes keys
return (
<DataContext.Provider value={{ data, setData }}>
{children}
</DataContext.Provider>
);
}
// Components add data but never remove it
function SomeComponent() {
const { setData } = useContext(DataContext);
useEffect(() => {
setData(`component-${id}`, { heavy: 'data' });
// ❌ No cleanup to remove this data
}, []);
}
// THE FIX
function DataProvider({ children }: { children: React.ReactNode }) {
const [data, setDataState] = useState<Record<string, any>>({});
const setData = useCallback((key: string, value: any) => {
setDataState((prev) => ({ ...prev, [key]: value }));
}, []);
// Add a remove function
const removeData = useCallback((key: string) => {
setDataState((prev) => {
const { [key]: _, ...rest } = prev;
return rest;
});
}, []);
return (
<DataContext.Provider value={{ data, setData, removeData }}>
{children}
</DataContext.Provider>
);
}
function SomeComponent() {
const { setData, removeData } = useContext(DataContext);
const dataKey = `component-${id}`;
useEffect(() => {
setData(dataKey, { heavy: 'data' });
// ✅ Cleanup removes the data
return () => removeData(dataKey);
}, [dataKey, setData, removeData]);
}
Leak 8: Ref Callbacks with External References
// THE LEAK
function VideoPlayer({ onTimeUpdate }: { onTimeUpdate: (time: number) => void }) {
const videoRef = useRef<HTMLVideoElement | null>(null);
useEffect(() => {
const video = videoRef.current;
if (!video) return;
const handleTimeUpdate = () => {
onTimeUpdate(video.currentTime);
};
video.addEventListener('timeupdate', handleTimeUpdate);
// ❌ If onTimeUpdate changes, old listener remains
}, []); // Missing onTimeUpdate in deps!
return <video ref={videoRef} />;
}
// THE FIX
function VideoPlayer({ onTimeUpdate }: { onTimeUpdate: (time: number) => void }) {
const videoRef = useRef<HTMLVideoElement | null>(null);
// Store latest callback in ref to avoid re-subscribing
const onTimeUpdateRef = useRef(onTimeUpdate);
useEffect(() => {
onTimeUpdateRef.current = onTimeUpdate;
}, [onTimeUpdate]);
useEffect(() => {
const video = videoRef.current;
if (!video) return;
const handleTimeUpdate = () => {
onTimeUpdateRef.current(video.currentTime);
};
video.addEventListener('timeupdate', handleTimeUpdate);
return () => {
video.removeEventListener('timeupdate', handleTimeUpdate);
};
}, []);
return <video ref={videoRef} />;
}
The React-Specific Patterns
Pattern: Large Component Trees in State
// THE LEAK
function DynamicRenderer() {
const [components, setComponents] = useState<React.ReactNode[]>([]);
const addComponent = () => {
// ❌ Storing React elements in state
setComponents((prev) => [
...prev,
<HeavyComponent key={Date.now()} data={generateLargeData()} />,
]);
};
// Even after elements are removed from render, old data might persist
// in previous state snapshots during async rendering
return (
<div>
{components}
<button onClick={addComponent}>Add</button>
</div>
);
}
// THE FIX: Store data, not elements
interface ComponentConfig {
id: string;
data: SomeData;
}
function DynamicRenderer() {
const [configs, setConfigs] = useState<ComponentConfig[]>([]);
const addComponent = () => {
setConfigs((prev) => [
...prev,
{ id: crypto.randomUUID(), data: generateData() },
]);
};
const removeComponent = (id: string) => {
setConfigs((prev) => prev.filter((c) => c.id !== id));
};
return (
<div>
{configs.map((config) => (
<HeavyComponent
key={config.id}
data={config.data}
onRemove={() => removeComponent(config.id)}
/>
))}
<button onClick={addComponent}>Add</button>
</div>
);
}
Pattern: Memoization Without Bounds
// THE LEAK
const expensiveCalculationCache = new Map<string, Result>();
function ExpensiveComponent({ input }: { input: string }) {
const result = useMemo(() => {
const cacheKey = input;
if (expensiveCalculationCache.has(cacheKey)) {
return expensiveCalculationCache.get(cacheKey)!;
}
const calculated = performExpensiveCalculation(input);
// ❌ Cache grows without limit
expensiveCalculationCache.set(cacheKey, calculated);
return calculated;
}, [input]);
return <div>{result}</div>;
}
// THE FIX: useMemo handles this automatically
function ExpensiveComponent({ input }: { input: string }) {
// useMemo already caches, no need for external cache
const result = useMemo(() => {
return performExpensiveCalculation(input);
}, [input]);
return <div>{result}</div>;
}
// If you NEED cross-component caching, use bounded cache
const cache = new LRUCache<string, Result>({ max: 100 });
function ExpensiveComponent({ input }: { input: string }) {
const result = useMemo(() => {
const cached = cache.get(input);
if (cached) return cached;
const calculated = performExpensiveCalculation(input);
cache.set(input, calculated);
return calculated;
}, [input]);
return <div>{result}</div>;
}
Pattern: React Query / SWR Cache Growth
// Query libraries cache by default - understand the implications
// React Query: Default cache time is 5 minutes
// After 5 minutes of no observers, data is garbage collected
// But if you have infinite queries or many unique keys:
function ProductSearch({ query }: { query: string }) {
// Every unique query creates a cache entry
const { data } = useQuery({
queryKey: ['search', query],
queryFn: () => searchProducts(query),
});
// If users search 1000 different terms, that's 1000 cache entries
// Most will be garbage collected after 5 minutes, but...
}
// Potential issues:
// 1. cacheTime: Infinity (never garbage collected)
// 2. Too many active queries (all have observers)
// 3. Large response data
// THE FIX: Configure cache appropriately
const queryClient = new QueryClient({
defaultOptions: {
queries: {
// Inactive queries garbage collected after 5 min (default)
gcTime: 1000 * 60 * 5,
// But for search, maybe shorter
// staleTime: 1000 * 30, // 30 seconds
},
},
});
// For specific query types, override
function ProductSearch({ query }: { query: string }) {
const { data } = useQuery({
queryKey: ['search', query],
queryFn: () => searchProducts(query),
gcTime: 1000 * 60, // 1 minute for search queries
});
}
// Monitor cache size
useEffect(() => {
const interval = setInterval(() => {
const cache = queryClient.getQueryCache();
console.log('Query cache size:', cache.getAll().length);
}, 30000);
return () => clearInterval(interval);
}, []);
Detecting Memory Leaks
Chrome DevTools Memory Panel
Step-by-step leak detection:
1. ESTABLISH BASELINE
├── Open DevTools → Memory tab
├── Take heap snapshot (Snapshot 1)
└── Note the total size
2. REPRODUCE THE SCENARIO
├── Navigate to the suspected component
├── Interact with it (open modals, scroll lists, etc.)
├── Navigate away
├── Force garbage collection (click trash icon)
└── Take another snapshot (Snapshot 2)
3. COMPARE
├── Select Snapshot 2
├── Change view to "Comparison"
├── Compare to Snapshot 1
└── Look for objects with positive "# Delta"
4. INVESTIGATE
├── Filter by constructor (e.g., "Detached")
├── Look for "Detached HTMLDivElement" etc.
├── Expand to see retainer tree
└── Find what's holding the reference
Finding Detached DOM Nodes
// In DevTools Console
// Find detached DOM elements
// These are DOM nodes that were removed from the document
// but are still retained in memory
// Take heap snapshot, then in Console:
// Search for "Detached" in the snapshot
// Or programmatically check for common patterns:
function checkForDetachedNodes() {
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
mutation.removedNodes.forEach((node) => {
// Log removed nodes - if these grow, you have a leak
console.log('Removed:', node);
});
});
});
observer.observe(document.body, {
childList: true,
subtree: true,
});
return () => observer.disconnect();
}
Memory Timeline Analysis
Using Performance tab:
1. Open DevTools → Performance tab
2. Check "Memory" checkbox
3. Click Record
4. Perform the suspected leaky operations multiple times
5. Click Stop
WHAT TO LOOK FOR:
────────────────────────────────────────────────────────────────
Good (no leak):
Memory ▲
│ ╱╲ ╱╲ ╱╲
│ ╱ ╲ ╱ ╲ ╱ ╲
│ ╱ ╲╱ ╲╱ ╲
└──────────────────────► Time
Memory grows and shrinks with GC
Bad (leak):
Memory ▲
│ ╱────
│ ╱───╱
│ ╱───╱
│╱──╱
└──────────────────────► Time
Memory only grows, never fully recovers
Custom Leak Detection Hook
// hooks/useMemoryMonitor.ts
export function useMemoryMonitor(label: string) {
useEffect(() => {
const startMemory = (performance as any).memory?.usedJSHeapSize;
console.log(`[${label}] Mount - Heap: ${formatBytes(startMemory)}`);
return () => {
// Force GC if available (only works with --expose-gc flag)
if (typeof global !== 'undefined' && (global as any).gc) {
(global as any).gc();
}
setTimeout(() => {
const endMemory = (performance as any).memory?.usedJSHeapSize;
const diff = endMemory - startMemory;
if (diff > 1024 * 1024) { // More than 1MB retained
console.warn(
`[${label}] Potential leak - ${formatBytes(diff)} retained after unmount`
);
}
}, 1000);
};
}, [label]);
}
function formatBytes(bytes: number): string {
return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
}
// Usage
function SuspectedComponent() {
useMemoryMonitor('SuspectedComponent');
// ... component code
}
Automated Leak Testing
// __tests__/memory-leaks.test.ts
import { render, cleanup } from '@testing-library/react';
describe('Memory Leak Tests', () => {
afterEach(cleanup);
it('should not leak memory after multiple mount/unmount cycles', async () => {
// Get initial memory (if available)
const getMemory = () =>
(performance as any).memory?.usedJSHeapSize ?? 0;
const initialMemory = getMemory();
const memoryReadings: number[] = [];
// Mount/unmount many times
for (let i = 0; i < 100; i++) {
const { unmount } = render(<SuspectedComponent />);
unmount();
}
// Force GC if available
if ((global as any).gc) {
(global as any).gc();
}
// Wait for cleanup
await new Promise((r) => setTimeout(r, 1000));
const finalMemory = getMemory();
const memoryIncrease = finalMemory - initialMemory;
// Allow some variance, but flag significant growth
expect(memoryIncrease).toBeLessThan(5 * 1024 * 1024); // 5MB threshold
});
it('should cleanup event listeners on unmount', () => {
const addEventListenerSpy = jest.spyOn(window, 'addEventListener');
const removeEventListenerSpy = jest.spyOn(window, 'removeEventListener');
const { unmount } = render(<ComponentWithListeners />);
const addedListeners = addEventListenerSpy.mock.calls.length;
unmount();
const removedListeners = removeEventListenerSpy.mock.calls.length;
expect(removedListeners).toBeGreaterThanOrEqual(addedListeners);
});
});
Production Monitoring
Real User Memory Monitoring
// lib/memory-monitor.ts
interface MemoryReading {
timestamp: number;
usedJSHeapSize: number;
totalJSHeapSize: number;
jsHeapSizeLimit: number;
route: string;
}
class MemoryMonitor {
private readings: MemoryReading[] = [];
private intervalId: number | null = null;
start(intervalMs = 30000) {
if (!('memory' in performance)) {
console.warn('Memory API not available');
return;
}
this.intervalId = window.setInterval(() => {
this.takeReading();
}, intervalMs);
// Also read on route changes
this.setupRouteMonitoring();
}
stop() {
if (this.intervalId) {
clearInterval(this.intervalId);
}
}
private takeReading() {
const memory = (performance as any).memory;
const reading: MemoryReading = {
timestamp: Date.now(),
usedJSHeapSize: memory.usedJSHeapSize,
totalJSHeapSize: memory.totalJSHeapSize,
jsHeapSizeLimit: memory.jsHeapSizeLimit,
route: window.location.pathname,
};
this.readings.push(reading);
// Keep last 100 readings
if (this.readings.length > 100) {
this.readings.shift();
}
// Check for concerning patterns
this.analyzeReadings();
}
private analyzeReadings() {
if (this.readings.length < 10) return;
const recent = this.readings.slice(-10);
const oldest = recent[0].usedJSHeapSize;
const newest = recent[recent.length - 1].usedJSHeapSize;
// Memory grew more than 50MB in last 10 readings
if (newest - oldest > 50 * 1024 * 1024) {
this.reportPotentialLeak(recent);
}
// Memory usage above 80% of limit
const latestReading = this.readings[this.readings.length - 1];
const usagePercent =
latestReading.usedJSHeapSize / latestReading.jsHeapSizeLimit;
if (usagePercent > 0.8) {
this.reportHighMemoryUsage(latestReading);
}
}
private reportPotentialLeak(readings: MemoryReading[]) {
// Send to your monitoring service
analytics.track('potential_memory_leak', {
readings: readings.map((r) => ({
mb: Math.round(r.usedJSHeapSize / 1024 / 1024),
route: r.route,
})),
userAgent: navigator.userAgent,
sessionDuration: Date.now() - sessionStartTime,
});
}
private reportHighMemoryUsage(reading: MemoryReading) {
analytics.track('high_memory_usage', {
usedMB: Math.round(reading.usedJSHeapSize / 1024 / 1024),
limitMB: Math.round(reading.jsHeapSizeLimit / 1024 / 1024),
route: reading.route,
});
}
private setupRouteMonitoring() {
// Listen for route changes
const originalPushState = history.pushState;
history.pushState = (...args) => {
originalPushState.apply(history, args);
setTimeout(() => this.takeReading(), 1000);
};
}
// Get current stats for debugging
getStats() {
if (this.readings.length === 0) return null;
const latest = this.readings[this.readings.length - 1];
const oldest = this.readings[0];
return {
currentMB: Math.round(latest.usedJSHeapSize / 1024 / 1024),
growthMB: Math.round(
(latest.usedJSHeapSize - oldest.usedJSHeapSize) / 1024 / 1024
),
readingCount: this.readings.length,
duration: latest.timestamp - oldest.timestamp,
};
}
}
export const memoryMonitor = new MemoryMonitor();
// Start in production
if (process.env.NODE_ENV === 'production') {
memoryMonitor.start();
}
Debug Component for Development
// components/MemoryDebugger.tsx
function MemoryDebugger() {
const [stats, setStats] = useState<any>(null);
const [visible, setVisible] = useState(false);
useEffect(() => {
if (!visible) return;
const interval = setInterval(() => {
if ('memory' in performance) {
const memory = (performance as any).memory;
setStats({
used: (memory.usedJSHeapSize / 1024 / 1024).toFixed(2),
total: (memory.totalJSHeapSize / 1024 / 1024).toFixed(2),
limit: (memory.jsHeapSizeLimit / 1024 / 1024).toFixed(2),
});
}
}, 1000);
return () => clearInterval(interval);
}, [visible]);
if (process.env.NODE_ENV !== 'development') return null;
return (
<div className="fixed bottom-4 right-4 z-50">
<button
onClick={() => setVisible(!visible)}
className="bg-gray-800 text-white px-2 py-1 rounded text-xs"
>
{visible ? 'Hide' : 'Memory'}
</button>
{visible && stats && (
<div className="mt-2 bg-gray-800 text-white p-3 rounded text-xs font-mono">
<div>Used: {stats.used} MB</div>
<div>Total: {stats.total} MB</div>
<div>Limit: {stats.limit} MB</div>
<button
onClick={() => {
if ((window as any).gc) (window as any).gc();
}}
className="mt-2 bg-red-600 px-2 py-1 rounded"
>
Force GC
</button>
</div>
)}
</div>
);
}
Prevention Strategies
ESLint Rules
// .eslintrc.js
module.exports = {
rules: {
// Warn on missing cleanup
'react-hooks/exhaustive-deps': 'warn',
// Custom rule ideas (would need implementation):
// 'no-uncleared-intervals': 'error',
// 'require-effect-cleanup': 'warn',
},
};
Custom Hook for Safe Subscriptions
// hooks/useSubscription.ts
/**
* Safe subscription hook that ensures cleanup
*/
export function useSubscription<T>(
subscribe: (callback: (value: T) => void) => () => void,
callback: (value: T) => void,
deps: DependencyList
) {
const callbackRef = useRef(callback);
callbackRef.current = callback;
useEffect(() => {
const unsubscribe = subscribe((value) => {
callbackRef.current(value);
});
// TypeScript ensures subscribe returns cleanup function
return unsubscribe;
}, deps);
}
// Usage
function Component() {
const [price, setPrice] = useState(0);
useSubscription(
(callback) => priceService.subscribe('BTC', callback),
(newPrice) => setPrice(newPrice),
[] // deps
);
}
Safe Async Hook
// hooks/useAsyncEffect.ts
export function useAsyncEffect(
effect: (signal: AbortSignal) => Promise<void>,
deps: DependencyList
) {
useEffect(() => {
const controller = new AbortController();
effect(controller.signal).catch((error) => {
if (error.name === 'AbortError') return;
console.error('useAsyncEffect error:', error);
});
return () => controller.abort();
}, deps);
}
// Usage
function Component({ id }: { id: string }) {
const [data, setData] = useState(null);
useAsyncEffect(
async (signal) => {
const response = await fetch(`/api/data/${id}`, { signal });
const json = await response.json();
setData(json); // Safe - will abort if unmounted
},
[id]
);
}
Safe Interval Hook
// hooks/useInterval.ts
export function useInterval(callback: () => void, delay: number | null) {
const savedCallback = useRef(callback);
// Remember the latest callback
useEffect(() => {
savedCallback.current = callback;
}, [callback]);
// Set up the interval
useEffect(() => {
if (delay === null) return;
const id = setInterval(() => savedCallback.current(), delay);
return () => clearInterval(id);
}, [delay]);
}
// Usage
function Counter() {
const [count, setCount] = useState(0);
useInterval(() => {
setCount((c) => c + 1);
}, 1000);
return <div>{count}</div>;
}
Quick Reference
The useEffect Cleanup Checklist
useEffect(() => {
// SETUP: Anything that creates references
const subscription = subscribe(); // ✓ Needs cleanup
const interval = setInterval(...); // ✓ Needs cleanup
const timeout = setTimeout(...); // ✓ Needs cleanup
const listener = () => {};
window.addEventListener('x', listener); // ✓ Needs cleanup
const ws = new WebSocket(...); // ✓ Needs cleanup
const controller = new AbortController();// ✓ Needs cleanup (abort)
// CLEANUP: Mirror of setup
return () => {
subscription.unsubscribe();
clearInterval(interval);
clearTimeout(timeout);
window.removeEventListener('x', listener);
ws.close();
controller.abort();
};
}, [deps]);
Common Leak Patterns
LEAK PATTERN FIX
─────────────────────────────────────────────────────────────────
Event listener without cleanup → Add removeEventListener in cleanup
Timer without clear → Add clearInterval/clearTimeout
Async operation without abort → Use AbortController or mounted flag
WebSocket without close → Add ws.close() in cleanup
Subscription without unsubscribe → Add unsubscribe in cleanup
Module-level cache → Use LRU cache or WeakMap
Closure in singleton → Ensure singleton has remove method
Context data without removal → Add removal function to context
Memory Debugging Commands
// Chrome DevTools Console
// Take heap snapshot programmatically
// (requires DevTools open)
console.profile('memory');
// ... do stuff ...
console.profileEnd('memory');
// Check current memory (Chrome only)
console.log(performance.memory);
// Find detached DOM nodes
// In Memory tab, take snapshot, filter by "Detached"
// Check for event listener leaks
getEventListeners(window);
getEventListeners(document);
// Monitor specific object
// In Memory tab, right-click object → "Store as global variable"
// Then check in later snapshot if temp1 still exists
Closing Thoughts
The garbage collector handles most memory management, but it can only collect what's unreachable. React's patterns — closures, refs, effects, subscriptions — create many opportunities for accidental references that keep objects alive.
The good news: most leaks follow predictable patterns. Event listeners, timers, async operations, and subscriptions account for the vast majority. If every useEffect that sets something up also tears it down, you've prevented 90% of potential leaks.
The remaining 10% — module-level caches, closure captures in long-lived objects, context accumulation — require more careful design. But they're also rarer and usually easier to spot in code review.
Build the habit: every addEventListener needs removeEventListener. Every setInterval needs clearInterval. Every subscription needs unsubscription. Every async operation needs abort handling.
The garbage collector is powerful, but it's not psychic. You have to break the references yourself.
What did you think?