Back to React
React
medium
mid

How would you debug and fix a memory leak in a React application?

1) Reproduce — repeatedly trigger the suspected leaky path. 2) DevTools Memory panel — take heap snapshot, repeat the leak path, take another, compare; look at retained size + 'Detached HTMLElement' rows. 3) Identify the retainer — usually a missing listener removal, interval not cleared, or closure over big data. 4) Fix with cleanup in useEffect return / AbortController / bounded cache. 5) Verify heap returns to baseline.

4 min read·~10 min to think through

Cross-link to [[how-do-you-handle-memory-leaks-in-react-apps]] for prevention. This question is about debugging an existing leak.

Step 1 — reproduce

Define the leak scenario:

"Open the modal, close it, repeat 50 times. Heap grows linearly."

Without a repro, you can't measure.

Step 2 — snapshot before/after

DevTools → Memory → "Heap snapshot."

  1. Snapshot at baseline.
  2. Do the suspected leaky action N times (e.g. open/close modal 50x).
  3. Snapshot again.
  4. Compare: Comparison mode shows what's "new" since the first snapshot.

Step 3 — find the retainer

In the comparison view:

  • Look at "Detached HTMLElement" — DOM nodes still in JS memory after removal.
  • Sort by retained size — what's holding the memory.
  • Click a leaking object → "Retainers" tab shows what's referencing it.

The path from a GC root to the leaked object tells you who's holding it.

Common React leaks + fixes

Listener not removed

tsx
// LEAK
useEffect(() => {
  window.addEventListener('resize', onResize);
}, []);

// FIX
useEffect(() => {
  window.addEventListener('resize', onResize);
  return () => window.removeEventListener('resize', onResize);
}, []);

Or use AbortController for many at once:

tsx
useEffect(() => {
  const ac = new AbortController();
  window.addEventListener('resize', onResize, { signal: ac.signal });
  document.addEventListener('keydown', onKey, { signal: ac.signal });
  return () => ac.abort();
}, []);

Interval / timeout not cleared

tsx
useEffect(() => {
  const id = setInterval(tick, 1000);
  return () => clearInterval(id);
}, []);

In-flight fetch after unmount

tsx
useEffect(() => {
  const ac = new AbortController();
  fetch(url, { signal: ac.signal }).then(setData).catch((e) => { if (e.name !== 'AbortError') ... });
  return () => ac.abort();
}, []);

Store subscription not unsubscribed

tsx
useEffect(() => {
  const unsub = store.subscribe(onChange);
  return unsub;
}, []);

Closure over a big object

tsx
// LEAK
useEffect(() => {
  const giantData = computeGiant();
  setTimeout(() => use(giantData.summary), 60_000);   // keeps giantData alive 60s
}, []);

// FIX
useEffect(() => {
  const giantData = computeGiant();
  const summary = giantData.summary;
  setTimeout(() => use(summary), 60_000);             // only summary kept
}, []);

Unbounded cache

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

Bound with LRU + cap or TTL. Use WeakMap if keys are objects that can be GC'd.

Detached DOM held by JS

tsx
const ref = useRef();
useEffect(() => {
  window.someGlobal = () => use(ref.current);   // global retains the node
}, []);

Avoid retaining DOM via globals.

Step 4 — Strict Mode in dev

React 18 Strict Mode double-mounts effects. If your cleanup is missing, the second mount registers a duplicate subscription. Run with Strict Mode to surface bugs early.

Step 5 — verify

Repeat the leak scenario; snapshot. Heap should return to ~baseline after GC. Force GC in DevTools or wait.

Production detection

  • performance.memory.usedJSHeapSize (Chrome) — sample periodically.
  • Sentry / RUM tools that flag heap growth.
  • User reports of slow tabs after long sessions.

Interview framing

"Reproduce the leak path repeatably. Heap snapshot before and after; compare in DevTools Memory panel. Look at 'Detached HTMLElement' and retainers — they tell you what's holding the memory. Most React leaks are: missing useEffect cleanup (listener, interval, subscription, fetch), closures holding big data, unbounded caches, or DOM refs retained via globals. Fix with cleanup in useEffect return (or AbortController for many at once), bounded caches, WeakMap for object keys. Re-snapshot to verify return to baseline. Strict Mode in dev surfaces missing cleanups by double-mounting."

Follow-up questions

  • Walk me through finding a leak with DevTools.
  • Why does Strict Mode help?
  • When is WeakMap the right choice?

Common mistakes

  • Missing useEffect cleanup.
  • Global retention of refs.
  • Unbounded caches.

Performance considerations

  • Leak detection workflow + Strict Mode + AbortController convention prevents most issues.

Edge cases

  • WebSocket reconnect leaks.
  • Workers not terminated.
  • Cycles between React state and external library.

Real-world examples

  • Long-running SPAs (Slack, Gmail), dashboard apps, editor apps.

Senior engineer discussion

Seniors lead with the snapshot-compare workflow and use AbortController + Strict Mode as conventions to prevent leaks.

Related questions