Detecting & Fixing Memory Leaks — Find and eliminate memory leaks that degrade performance over time
Recipe
// The core pattern: every useEffect that allocates must clean up
useEffect(() => {
// ALLOCATE: subscription, listener, timer, fetch
const controller = new AbortController();
const intervalId = setInterval(pollData, 5000);
window.addEventListener("resize", handleResize);
fetchData({ signal: controller.signal });
// CLEANUP: runs on unmount and before re-running effect
return () => {
controller.abort();
clearInterval(intervalId);
window.removeEventListener("resize", handleResize);
};
}, []);When to reach for this: When your app's memory usage grows over time during navigation, when Chrome DevTools shows an increasing JS heap size, or when users report the app becoming slower the longer they use it.
Working Example
// ---- BEFORE: Component with 5 memory leaks ----
function LiveDashboard({ userId }: { userId: string }) {
const [data, setData] = useState<DashboardData | null>(null);
const [mousePos, setMousePos] = useState({ x: 0, y: 0 });
const [notifications, setNotifications] = useState<string[]>([]);
const chartRef = useRef<HTMLCanvasElement>(null);
// LEAK 1: Fetch without abort — updates state after unmount
useEffect(() => {
async function loadData() {
const res = await fetch(`/api/dashboard/${userId}`);
const json = await res.json();
setData(json); // May run after unmount!
}
loadData();
}, [userId]);
// LEAK 2: Interval never cleared — keeps running after unmount
useEffect(() => {
setInterval(async () => {
const res = await fetch(`/api/dashboard/${userId}/live`);
const json = await res.json();
setData(json); // Runs forever, even after navigating away
}, 5000);
}, [userId]);
// LEAK 3: Event listener never removed
useEffect(() => {
window.addEventListener("mousemove", (e) => {
setMousePos({ x: e.clientX, y: e.clientY });
});
}, []);
// LEAK 4: WebSocket never closed
useEffect(() => {
const ws = new WebSocket(`wss://api.example.com/ws/${userId}`);
ws.onmessage = (event) => {
setNotifications((prev) => [...prev, event.data]);
};
// No cleanup — WebSocket stays open
}, [userId]);
// LEAK 5: Canvas context and large data retained via closure
useEffect(() => {
const ctx = chartRef.current?.getContext("2d");
const hugeDataSet = new Float64Array(1_000_000); // 8MB allocation
function drawChart() {
// Closure retains hugeDataSet even after component unmounts
ctx?.clearRect(0, 0, 800, 400);
// ... draw using hugeDataSet
}
drawChart();
// hugeDataSet is never released because drawChart retains a reference
}, [data]);
return (
<div>
<canvas ref={chartRef} width={800} height={400} />
<p>Mouse: {mousePos.x}, {mousePos.y}</p>
<ul>
{notifications.map((n, i) => (
<li key={i}>{n}</li>
))}
</ul>
</div>
);
}
// ---- AFTER: All 5 leaks fixed — proper cleanup on every effect ----
function LiveDashboard({ userId }: { userId: string }) {
const [data, setData] = useState<DashboardData | null>(null);
const [mousePos, setMousePos] = useState({ x: 0, y: 0 });
const [notifications, setNotifications] = useState<string[]>([]);
const chartRef = useRef<HTMLCanvasElement>(null);
// FIX 1: AbortController cancels fetch on unmount or userId change
useEffect(() => {
const controller = new AbortController();
async function loadData() {
try {
const res = await fetch(`/api/dashboard/${userId}`, {
signal: controller.signal,
});
const json = await res.json();
setData(json);
} catch (err) {
if (err instanceof DOMException && err.name === "AbortError") {
// Expected when component unmounts — not an error
return;
}
throw err;
}
}
loadData();
return () => controller.abort();
}, [userId]);
// FIX 2: Interval cleared on unmount, AbortController for each poll
useEffect(() => {
const controller = new AbortController();
const intervalId = setInterval(async () => {
try {
const res = await fetch(`/api/dashboard/${userId}/live`, {
signal: controller.signal,
});
const json = await res.json();
setData(json);
} catch (err) {
if (err instanceof DOMException && err.name === "AbortError") return;
console.error("Poll failed:", err);
}
}, 5000);
return () => {
clearInterval(intervalId);
controller.abort();
};
}, [userId]);
// FIX 3: Named handler + removeEventListener on cleanup
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
setMousePos({ x: e.clientX, y: e.clientY });
};
window.addEventListener("mousemove", handleMouseMove);
return () => window.removeEventListener("mousemove", handleMouseMove);
}, []);
// FIX 4: WebSocket closed on cleanup
useEffect(() => {
const ws = new WebSocket(`wss://api.example.com/ws/${userId}`);
ws.onmessage = (event) => {
setNotifications((prev) => [...prev, event.data]);
};
ws.onerror = (error) => {
console.error("WebSocket error:", error);
};
return () => {
ws.close();
};
}, [userId]);
// FIX 5: Cleanup nullifies large data, uses ref for mutable binding
useEffect(() => {
const ctx = chartRef.current?.getContext("2d");
let hugeDataSet: Float64Array | null = new Float64Array(1_000_000);
function drawChart() {
if (!hugeDataSet || !ctx) return;
ctx.clearRect(0, 0, 800, 400);
// ... draw using hugeDataSet
}
drawChart();
return () => {
hugeDataSet = null; // Release 8MB — allows garbage collection
};
}, [data]);
return (
<div>
<canvas ref={chartRef} width={800} height={400} />
<p>Mouse: {mousePos.x}, {mousePos.y}</p>
<ul>
{notifications.map((n, i) => (
<li key={i}>{n}</li>
))}
</ul>
</div>
);
}What this demonstrates:
- AbortController cancels in-flight fetches on unmount, preventing state updates on unmounted components
clearIntervalstops polling when the component is no longer visible- Named event handler functions allow proper
removeEventListenercleanup - WebSocket connections are explicitly closed
- Large data allocations are nullified in cleanup to allow garbage collection
- After fixes: heap usage drops by ~50MB after navigating away from the dashboard 10 times
Deep Dive
How It Works
- useEffect cleanup function — The function returned from
useEffectruns in two scenarios: (1) before the effect re-runs due to dependency changes, and (2) when the component unmounts. This is where all resource deallocation must happen. - Stale closure leaks — When an async operation (fetch, timeout, WebSocket message) completes after unmount, the callback still holds a reference to the component's state setter via closure. Calling
setStateon an unmounted component is a no-op in React 18+, but the closure itself prevents garbage collection of everything it references. - Detached DOM nodes — If a ref to a DOM node is stored in a closure or external variable and the node is removed from the DOM, the node and its entire subtree remain in memory. This is common with chart libraries and third-party DOM-manipulating code.
- Event listener accumulation — Adding event listeners in
useEffectwithout cleanup means each re-mount (navigation) adds another listener. After 10 navigations, you have 10 listeners all firing on every event. - Chrome DevTools Memory tab — Take a heap snapshot before and after navigating to/from a component. Compare snapshots to find objects that should have been garbage collected. Filter by "Detached" to find detached DOM nodes.
Variations
AbortController for multiple concurrent fetches:
useEffect(() => {
const controller = new AbortController();
async function loadAll() {
const [users, orders, metrics] = await Promise.all([
fetch("/api/users", { signal: controller.signal }),
fetch("/api/orders", { signal: controller.signal }),
fetch("/api/metrics", { signal: controller.signal }),
]);
// One abort cancels all three
setData({
users: await users.json(),
orders: await orders.json(),
metrics: await metrics.json(),
});
}
loadAll().catch((err) => {
if (err.name !== "AbortError") throw err;
});
return () => controller.abort();
}, []);WeakRef for optional references:
// Advanced: WeakRef allows garbage collection of the referenced object
function useWeakCallback<T extends object>(target: T, callback: (target: T) => void) {
const weakRef = useRef(new WeakRef(target));
useEffect(() => {
const intervalId = setInterval(() => {
const obj = weakRef.current.deref();
if (obj) {
callback(obj);
} else {
// Object was garbage collected — stop polling
clearInterval(intervalId);
}
}, 1000);
return () => clearInterval(intervalId);
}, [callback]);
}Detecting leaks with heap snapshots:
1. Open Chrome DevTools -> Memory tab
2. Take Heap Snapshot (Snapshot 1 — baseline)
3. Navigate to the suspected leaky page
4. Navigate away from the page
5. Click the garbage collection button (trash can icon)
6. Take Heap Snapshot (Snapshot 2)
7. Select Snapshot 2 -> Change view to "Comparison"
8. Sort by "Delta" column — objects with positive deltas are potential leaks
9. Look for: Detached HTMLDivElement, EventListener, Closure, Array
TypeScript Notes
- The
AbortControllerandAbortSignaltypes are built into the DOM lib. No additional types needed. - Type the cleanup function return as
voidorundefined(useEffect forbids returning anything else). WeakRef<T>requiresT extends object. Primitives cannot be weakly referenced.FinalizationRegistry<T>callback receives the held value of typeTwhen the target is collected.
Gotchas
-
Anonymous functions prevent cleanup —
window.addEventListener("resize", () => {...})cannot be removed becauseremoveEventListenerrequires the same function reference. Fix: Always use named functions or store the reference in a variable. -
Strict Mode double-mounts masking leaks — React 18+ Strict Mode mounts, unmounts, and remounts every component in development. This can make leaks appear as "normal" behavior. Fix: A properly cleaned-up component works correctly through Strict Mode's mount cycle. If you see duplicated listeners or connections, your cleanup is incomplete.
-
Missing dependency in effect causes stale cleanup — If the cleanup function references a variable that is not in the dependency array, it may close over a stale value. Fix: Include all referenced variables in the dependency array, or use refs for mutable values that should not trigger re-runs.
-
Third-party library cleanup — Libraries like chart.js, mapbox-gl, or video players allocate internal resources. Fix: Call the library's destroy/dispose method in the cleanup function:
chart.destroy(),map.remove(),player.dispose(). -
React 18+ no longer warns about state updates on unmounted components — The warning was removed because it produced too many false positives. But the underlying leak still exists. Fix: Use AbortController and cleanup functions regardless of whether React warns you.
-
Timers with closure over growing arrays —
setIntervalcallbacks that push to an array create an ever-growing data structure even if the component re-mounts with a fresh state. Fix: Clear the interval in cleanup and ensure the closure does not reference accumulators from previous mounts.
Alternatives
| Approach | Trade-off |
|---|---|
useEffect cleanup | Built-in; requires manual discipline for every effect |
| AbortController | Standard API; only works with fetch and APIs that accept AbortSignal |
| TanStack Query | Automatic fetch lifecycle management; adds dependency |
| SWR | Automatic request deduplication and cleanup; adds dependency |
use hook (React 19) | Promise-based; works with Suspense, no manual cleanup needed |
| RxJS | Observable subscriptions with automatic cleanup; heavy dependency |
| Zustand subscriptions | Store-managed; cleanup via subscribe return value |
FAQs
What is the most common pattern for preventing memory leaks in useEffect?
Every useEffect that allocates resources must return a cleanup function:
useEffect(() => {
const controller = new AbortController();
fetchData({ signal: controller.signal });
return () => controller.abort();
}, []);The cleanup runs on unmount and before the effect re-runs.
Why does React 18+ no longer warn about state updates on unmounted components?
The warning was removed because it produced too many false positives. However, the underlying leak still exists -- the closure retains references to the component's state and prevents garbage collection. Use AbortController and cleanup functions regardless.
How do you cancel an in-flight fetch request when a component unmounts?
Use AbortController and pass its signal to fetch:
useEffect(() => {
const controller = new AbortController();
fetch(url, { signal: controller.signal })
.then((r) => r.json())
.then(setData)
.catch((err) => {
if (err.name === "AbortError") return;
throw err;
});
return () => controller.abort();
}, [url]);Gotcha: Why can't you remove an anonymous event listener?
window.addEventListener("resize", () => {...}) cannot be removed because removeEventListener requires the exact same function reference.
Fix: Store the handler in a variable:
const handleResize = () => { /* ... */ };
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);Gotcha: How does Strict Mode's double-mount affect memory leak detection?
React 18+ Strict Mode mounts, unmounts, and remounts every component in development. This can make leaks appear as "normal" behavior. A properly cleaned-up component works correctly through this cycle -- if you see duplicated listeners or connections, your cleanup is incomplete.
How do you detect memory leaks using Chrome DevTools heap snapshots?
- Take a heap snapshot (baseline)
- Navigate to the suspected leaky page, then navigate away
- Click the garbage collection button
- Take a second snapshot and select "Comparison" view
- Sort by "Delta" column -- objects with positive deltas are potential leaks
- Look for: Detached HTMLDivElement, EventListener, Closure, Array
How should you handle large data allocations in useEffect to prevent leaks?
Nullify large data in the cleanup function to allow garbage collection:
useEffect(() => {
let hugeData: Float64Array | null = new Float64Array(1_000_000);
drawChart(hugeData);
return () => { hugeData = null; };
}, [data]);What is the correct cleanup for WebSocket connections?
Close the WebSocket in the cleanup function:
useEffect(() => {
const ws = new WebSocket(`wss://api.example.com/ws/${id}`);
ws.onmessage = (event) => setData(event.data);
return () => ws.close();
}, [id]);What TypeScript constraint applies to WeakRef?
WeakRef<T> requires T extends object. Primitives (strings, numbers, booleans) cannot be weakly referenced. The cleanup function return type from useEffect must be void or undefined.
How do you handle cleanup for third-party libraries like chart.js or mapbox-gl?
Call the library's destroy/dispose method in the cleanup function:
useEffect(() => {
const chart = new Chart(canvasRef.current, config);
return () => chart.destroy();
}, [config]);Libraries that manipulate the DOM directly retain detached nodes if not properly destroyed.
Can a single AbortController cancel multiple concurrent fetch requests?
Yes. Pass the same signal to all fetch calls. Calling controller.abort() once cancels every request using that signal:
const [users, orders] = await Promise.all([
fetch("/api/users", { signal: controller.signal }),
fetch("/api/orders", { signal: controller.signal }),
]);What happens to setInterval callbacks that reference growing arrays after unmount?
The callback continues pushing to the array even if the component re-mounts with fresh state. The old closure retains the old array, creating an ever-growing data structure. Always clear the interval in cleanup.
Related
- Profiling — Chrome DevTools Memory tab for detecting memory leaks
- Data Fetching Performance — Fetch lifecycle management with TanStack Query and SWR
- Suspense & Streaming — Server-side data fetching eliminates client-side fetch cleanup
- Performance Checklist — Memory leak audit as part of the performance review process