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.

useEffect Cleanup vs No Cleanup Lifecycle Two parallel timelines from mount to unmount. The top timeline has no cleanup function: the timer and its closure over component state remain reachable after unmount, causing a leak. The bottom timeline returns a cleanup function that calls clearInterval, so the timer and closure are freed at unmount. No cleanup With cleanup Mount effect runs setInterval() closes over state Unmount no return Leak timer retained Mount effect runs setInterval() id stored Unmount return fires Freed clearInterval closure GC'd Every mount without cleanup adds one retained closure per navigation

Step-by-Step Fix

  1. 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 #Delta column shows a positive retained count roughly equal to the number of mount cycles.
  2. Confirm the effect has no return statement. Search the component for useEffect(() => { ... }) and check whether the arrow function’s body ends with a return that reverses every setInterval, addEventListener, or subscription call made inside it. Expected output: you can point to the exact line where cleanup is missing.
  3. 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 #Delta near zero.
  4. 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 ref with a second useEffect that runs on every render. Expected output: logging inside the interval callback shows the current value, not the value from mount time.
  5. 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 duplicate console.log calls 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.