Back to React
React
medium
mid

How do you detect a memory leak in a React or frontend application?

Chrome DevTools Memory tab: take a heap snapshot, navigate away from the suspect feature, force GC, take another snapshot. Compare. Filter by 'Detached HTMLElement' or by component class names. Performance Monitor shows growing heap or DOM node count over time. Common culprits: uncleaned event listeners, timers, subscriptions, refs holding unmounted DOM, closures capturing large state. StrictMode dev double-mount surfaces missing cleanups.

8 min read·~5 min to think through

Methodical workflow — measure, isolate, fix.

Symptoms

  • Browser tab gets slower over time, eventually crashes.
  • Heap snapshot keeps growing even after the user navigates around.
  • DOM node count increases after each navigation cycle.
  • Performance Monitor shows allocations without corresponding GC drops.

DevTools workflow

1. Performance Monitor (live view)

Open DevTools → ... menu → More tools → Performance monitor. Watch JS heap size and DOM node count while you exercise the feature. Steady growth across cycles = leak.

2. Heap snapshots (precise)

ts
DevTools → Memory tab → Heap snapshot
1. Take snapshot A
2. Navigate to the suspect feature, then back
3. Force GC (trash can icon)
4. Take snapshot B
5. Compare → 'Objects allocated between A and B'

Look for:

  • Detached HTMLElement — DOM nodes no longer in the tree but still referenced.
  • Component instance names appearing in growing counts.
  • Closures with unexpectedly large captured scopes.

3. Allocation timeline

DevTools → Memory → 'Allocation instrumentation on timeline' shows allocation rate over time. Spikes that don't free are leaks.

Common culprits in React

1. Event listeners.

tsx
useEffect(() => {
  const onResize = () => setSize(window.innerWidth);
  window.addEventListener('resize', onResize);
  // BUG: no cleanup
}, []);

Fix: return the cleanup.

tsx
return () => window.removeEventListener('resize', onResize);

2. Timers / intervals.

tsx
useEffect(() => {
  setInterval(tick, 1000); // BUG: never cleared
}, []);

Fix: const id = setInterval(...); return () => clearInterval(id);

3. Subscriptions.

tsx
useEffect(() => {
  const sub = stream.subscribe(handleData);
  return () => sub.unsubscribe();
}, []);

4. In-flight fetches that setState after unmount.

tsx
useEffect(() => {
  const ctrl = new AbortController();
  fetch(url, { signal: ctrl.signal }).then(r => setData(r));
  return () => ctrl.abort();
}, [url]);

5. Refs / global maps holding DOM.

tsx
const registry = new Map();
useEffect(() => {
  registry.set(id, ref.current); // never cleaned up
}, []);

Use WeakMap when you can't be sure of cleanup timing.

6. Closures capturing large state.

A long-lived handler (registered in a parent) that captures large data from a child can keep the child alive after unmount.

StrictMode helps

React 18 StrictMode in dev:

  • Mounts component
  • Unmounts immediately
  • Mounts again

Any effect that doesn't clean up correctly will behave wrong on the second mount. The bug surfaces in dev where it's cheap to fix.

Runtime guards

Some teams add cleanup audits:

tsx
useEffect(() => {
  let alive = true;
  doAsync().then(d => { if (alive) setData(d); });
  return () => { alive = false; };
}, []);

Profiling production

  • Sentry / Rollbar performance: real user monitoring shows session durations and crash rates.
  • Custom telemetry: log heap size periodically (performance.memory.usedJSHeapSize in Chrome).
  • Long sessions: dashboards left open for hours surface slow leaks that short tests miss.

Senior framing

Memory leaks in modern React are almost always missing cleanups in useEffect. The fix is mechanical once you spot the pattern. The hard part is detection — heap snapshots and Performance Monitor are the right tools. StrictMode catches a chunk of these in dev before they ship.

Follow-up questions

  • What does a 'detached HTMLElement' actually mean?
  • How does WeakMap help with reference-based leaks?
  • Why does StrictMode help find leaks early?

Common mistakes

  • Forgetting cleanup return from useEffect.
  • Calling setState after unmount — produces a warning AND can leak.
  • Adding listeners to window/document without removing on unmount.

Performance considerations

  • Memory leaks degrade performance over time. GC pauses get longer as the heap grows. Eventually the tab crashes or freezes. For long-lived apps (dashboards, chat) this is critical; for sessions under a minute, it rarely matters.

Edge cases

  • Service Workers can hold references that survive across navigations.
  • Memoized callbacks with large captured scopes can pin big objects in memory.
  • Browser extensions can hold references to your DOM and confuse leak hunting.

Real-world examples

  • Dashboards left open for 8 hours leak across navigations. Chat apps leak through unclosed event sources. SPAs accumulate listeners across route changes. RxJS without unsubscribe is a classic source.

Senior engineer discussion

Senior framing: most React leaks are mechanical — missing returns from useEffect. The interesting cases are external systems (Service Workers, IndexedDB, global event buses, third-party SDKs) that hold references across component lifecycles. Audit boundaries with externals.

Related questions