Back to React
React
medium
mid

How do you handle memory leaks in React applications?

Pair every subscription with cleanup: `useEffect` returns a teardown; remove listeners; clear intervals; abort fetches with AbortController; unsubscribe from stores. Avoid holding refs to large objects in long-lived closures. Bound caches (LRU). Detect with DevTools Memory snapshots (detached DOM, retained size). Strict Mode helps surface missing cleanup in dev.

4 min read·~8 min to think through

Common leak sources

SourceFix
addEventListener without removeCleanup in useEffect return; or AbortController signal.
setInterval / setTimeout not clearedclearInterval/Timeout in cleanup.
Store subscriptions (Zustand, Redux observers)Unsubscribe in cleanup.
In-flight fetch after unmountAbortController signal.
Detached DOM kept by JS refsNull refs in cleanup.
Unbounded caches / MapsLRU with cap.
Long-lived closures over big dataDrop refs after use.
Server-injected state captured in module scopeInitialize per-request in SSR; don't share across requests.

The standard cleanup shape

tsx
useEffect(() => {
  const ac = new AbortController();
  window.addEventListener("resize", onResize, { signal: ac.signal });
  const id = setInterval(tick, 1000);
  const unsub = store.subscribe(onChange);

  return () => {
    ac.abort();
    clearInterval(id);
    unsub();
  };
}, []);

One AbortController can cover many listeners.

Aborting fetch on unmount

tsx
useEffect(() => {
  const ac = new AbortController();
  fetch("/data", { signal: ac.signal })
    .then((r) => r.json())
    .then(setData)
    .catch((e) => { if (e.name !== "AbortError") setError(e); });
  return () => ac.abort();
}, []);

React Query / SWR

Library handles cancellation + dedup + cleanup for you. Strongly prefer over hand-rolled fetch + useState for server state.

Strict Mode

In React 18+, dev-only Strict Mode mounts/unmounts/remounts every component twice. Reveals missing cleanups because effects run, clean up, run again. If you see double API calls in dev only, the fix isn't to "disable strict mode" — it's to add cleanup so the second run is benign.

Detached DOM

A node removed from the DOM but still referenced from JS can't be GC'd. Symptoms: heap growth after navigation. Find via Memory panel → "Detached".

jsx
// BAD — closure holds a node ref forever
const ref = useRef();
useEffect(() => {
  const node = ref.current;
  window.heavyHandler = () => doStuff(node);    // global retention
}, []);

Avoid global retention; null out refs in cleanup if holding heavy nodes.

Unbounded caches

js
const cache = new Map();      // grows forever

Bound with LRU + cap or TTL. Or use WeakMap when key objects can be GC'd.

Detection workflow

  1. Reproduce in dev with Strict Mode on.
  2. Memory panel — heap snapshot, navigate, snapshot again, compare. Look at "Detached HTMLElement" and retainers.
  3. Performance Monitor (DevTools) — watch JS heap size during prolonged interaction.
  4. Reproduce a leak loop — open/close a modal 50 times; if heap grows linearly, leak.
  5. In production — track performance.memory.usedJSHeapSize (Chrome) trends.

Architecture-level

  • Long-lived SPA (Gmail, Slack) — more leak surface area.
  • Page-per-route SSR — heap reset on every nav.
  • Background tabs — Chrome discards eventually; recovery via session restore.

Interview framing

"Pair every subscription with cleanup. useEffect return is the natural place — clearInterval, removeEventListener (or AbortController signal), unsubscribe, abort in-flight fetches. AbortController is the cleanest pattern because one signal can clean up many listeners + fetches. For server state use React Query — it handles cancellation and dedup. Strict Mode in dev surfaces missing cleanup by double-mounting. Detect leaks with DevTools Memory panel heap snapshots + detached DOM report. Watch for unbounded caches and long-lived closures over big objects — bound with LRU or WeakMap."

Follow-up questions

  • Walk through finding a leak with DevTools.
  • Why does Strict Mode double-mount in dev?
  • How does AbortController integrate with React?

Common mistakes

  • Missing cleanup in useEffect.
  • Global retention of node refs.
  • Unbounded caches.
  • Ignoring Strict Mode warnings.

Performance considerations

  • Heap growth → eventual crash on mobile. Long GC pauses precede the crash; watch p75 INP.

Edge cases

  • WebSocket / SSE reconnection leaks.
  • Cycles between React state and external library.
  • Workers not terminated.

Real-world examples

  • Long-running SPAs, dashboard apps, editor apps.

Senior engineer discussion

Seniors instrument heap growth in production, gate AbortController + cleanup as a PR convention, and prefer library-managed lifecycles (React Query) over hand-rolled effect-based fetching.

Related questions