Fixing useEffect Cleanup Memory Leaks
If a component that subscribes to something, starts a timer, or adds an
event listener inside useEffect never returns a cleanup function, every
unmount leaves that subscription running against a component instance
the tree no longer renders — a defect covered generally under React component leaks
inside Framework-Specific Memory Optimization.
| Symptom | Root Cause | Immediate Action |
|---|---|---|
| Heap grows on each route change | Missing useEffect return |
Add cleanup function |
| Old data still logs after unmount | Stale closure in callback | Use a ref for current value |
| Duplicate network calls in dev | StrictMode double-invoke | Confirm cleanup runs cleanly |
| Fetch updates unmounted state | No request cancellation | Wire up AbortController |
| Listener count keeps climbing | addEventListener never removed |
Call removeEventListener |
Root Cause
useEffect runs an arbitrary side-effect function after React commits a
render, and the function you pass to it may optionally return a second
function — the cleanup function. React calls that returned function
before the effect re-runs and again when the component unmounts. When
the effect body sets up something external to React’s own tree (a
setInterval timer, a WebSocket subscription, a DOM event listener,
an in-flight fetch), the JavaScript engine has no way to know that
subscription is tied to a component’s lifetime. The garbage collector
only reclaims an object once nothing reachable from a GC root still
points to it, and a running timer or an open subscription callback is
exactly the kind of GC root path described in the wider discussion of
closure memory leaks: the interval’s callback closes over the
component’s props, state, and any DOM nodes it touched, so the whole
render tree for that mount stays retained until the timer itself is
cleared. This is also why React memory grows on every route change in single-page apps — each navigation mounts a
fresh copy of the component, and without a return statement in the
effect, each previous copy’s subscription is still alive underneath the
new one.
A second, subtler failure mode is the stale closure: even when a
cleanup function exists, if it references state or props captured
by the original render’s closure rather than the latest values, the
cleanup can call the wrong handler reference (React’s dependency array
compares by reference, not the removeEventListener target) and silently
fail to detach the listener it thinks it detached, leaving both the old
and new listeners registered.
Step-by-Step Fix
- Reproduce with a heap snapshot diff. Open DevTools →
Memory → Heap Snapshot, take one snapshot, navigate away and back to
the suspect component 5–10 times, then take a second snapshot and
use the Comparison view filtered by the component’s constructor
name. Expected output: the
#Deltacolumn shows a positive retained count roughly equal to the number of mount cycles. - Confirm the effect has no return statement. Search the
component for
useEffect(() => { ... })and check whether the arrow function’s body ends with areturnthat reverses everysetInterval,addEventListener, or subscription call made inside it. Expected output: you can point to the exact line where cleanup is missing. - Add the cleanup function. Store the timer ID, listener
reference, or subscription object in a local variable inside the
effect, then return a function that calls the matching teardown API
on that exact reference. Expected output: re-running the heap
snapshot diff from step 1 now shows a
#Deltanear zero. - Fix stale closures with a ref. If the cleanup or the timer
callback needs the latest state rather than the value from the
render that scheduled the effect, mirror that state into a
refwith a seconduseEffectthat runs on every render. Expected output: logging inside the interval callback shows the current value, not the value from mount time. - Run under StrictMode and re-verify. Wrap the app (or confirm
<React.StrictMode>already wraps it) so effects mount, clean up, and remount once in development. Expected output: the console shows exactly one mount/cleanup/mount sequence with no duplicateconsole.logcalls from the effect body, confirming the cleanup function is idempotent.
Command & Code Reference
A timer-based effect with no cleanup — this is the failing pattern to find and fix first:
// BUGGY: interval keeps running after unmount
useEffect(() => {
const id = setInterval(() => {
// closes over `count` from the render
// that created this effect (stale after
// unmount, and never cancelled)
console.log(count);
}, 1000);
// no return here — id is never cleared
}, []);
The corrected version with a cleanup function and a ref to avoid the stale closure:
// FIXED: cleanup clears the timer on unmount
const countRef = useRef(count);
useEffect(() => {
// keep ref in sync with latest render
countRef.current = count;
});
useEffect(() => {
const id = setInterval(() => {
// reads the current value via ref,
// not the stale closed-over `count`
console.log(countRef.current);
}, 1000);
// cleanup: runs on unmount AND before
// the effect re-runs on a dep change
return () => clearInterval(id);
}, []);
A fetch inside useEffect cancelled with AbortController so a
resolved response never calls setState on an unmounted component:
// FIXED: aborts fetch when component unmounts
useEffect(() => {
const controller = new AbortController();
fetch("/api/profile", { signal: controller.signal })
.then((res) => res.json())
.then((data) => setProfile(data))
.catch((err) => {
// ignore the expected AbortError
if (err.name !== "AbortError") throw err;
});
// cleanup: cancels the in-flight request
return () => controller.abort();
}, []);
Verification & Regression Prevention
Set a concrete budget rather than eyeballing the Memory tab: after 10 mount/unmount cycles of the same route in DevTools → Performance Monitor, JS heap size should return within 2 MB of its baseline once you force a GC (the trash-can icon in the Memory tab). A drift beyond 5 MB per 10 cycles indicates an effect is still leaking. For detached DOM specifically, cross-check with debugging detached DOM nodes in React components, which walks through the Detached Elements view for confirming zero retained nodes post-unmount.
Add the react-hooks/exhaustive-deps ESLint rule (from
eslint-plugin-react-hooks) at "error" severity in CI — it does not
catch every missing cleanup, but it catches the dependency-array
mismatches that cause the stale-closure variant of this bug, and it
fails the build rather than shipping silently:
// .eslintrc rules block
"rules": {
"react-hooks/exhaustive-deps": "error"
}
For a runtime guard, add a Playwright or Cypress test that navigates
between two routes 20 times and asserts performance.memory.usedJSHeapSize
(Chromium only) has not grown more than a fixed threshold, then wire
that test into the same CI job that runs your unit suite so a
regression fails the pull request instead of surfacing in production.
Frequently Asked Questions
Why does my useEffect cleanup function use stale state?
The cleanup function closes over the values from the render that scheduled it, so if a timer or listener callback reads state directly instead of through a ref, it sees the value at effect-creation time, not the latest render. Store the current value in a ref updated on every render, or add the state to the effect’s dependency array so a fresh effect (and fresh cleanup) is created for each value.
Does React StrictMode cause a real memory leak by double-invoking effects?
No. StrictMode intentionally mounts, cleans up, and remounts every effect once in development to surface missing cleanup functions early. If you see duplicate subscriptions or double network requests only in development, that is the cleanup function doing its job on the first pass; a genuine leak is when the count keeps climbing after repeated mount and unmount cycles in production builds.
Should I use AbortController for every fetch inside useEffect?
Yes for any fetch whose response updates component state, because an
unmounted component that still resolves a fetch and calls setState
retains the component instance and its closure until the promise
settles. Create the AbortController inside the effect, pass its signal
to fetch, and call controller.abort() in the cleanup function.
Related
- React Component Memory Leaks & Lifecycle Cleanup — the parent guide for this fix
- Why React memory grows on every route change
- Debugging detached DOM nodes in React components
- Event listener leaks and AbortController cleanup
- Closure memory leaks in modern JavaScript — the main section on retained closures