React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

memory-leaksuseEffectcleanupabort-controllerevent-listenersheap-snapshottimersclosures

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
  • clearInterval stops polling when the component is no longer visible
  • Named event handler functions allow proper removeEventListener cleanup
  • 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 useEffect runs 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 setState on 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 useEffect without 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 AbortController and AbortSignal types are built into the DOM lib. No additional types needed.
  • Type the cleanup function return as void or undefined (useEffect forbids returning anything else).
  • WeakRef<T> requires T extends object. Primitives cannot be weakly referenced.
  • FinalizationRegistry<T> callback receives the held value of type T when the target is collected.

Gotchas

  • Anonymous functions prevent cleanupwindow.addEventListener("resize", () => {...}) cannot be removed because removeEventListener requires 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 arrayssetInterval callbacks 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

ApproachTrade-off
useEffect cleanupBuilt-in; requires manual discipline for every effect
AbortControllerStandard API; only works with fetch and APIs that accept AbortSignal
TanStack QueryAutomatic fetch lifecycle management; adds dependency
SWRAutomatic request deduplication and cleanup; adds dependency
use hook (React 19)Promise-based; works with Suspense, no manual cleanup needed
RxJSObservable subscriptions with automatic cleanup; heavy dependency
Zustand subscriptionsStore-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.