Skip to main content
Adzbyte
DevelopmentPerformance

Memory Leaks in React: How I Found Mine with Chrome DevTools

Adrian Saycon
Adrian Saycon
March 19, 20263 min read
Memory Leaks in React: How I Found Mine with Chrome DevTools

The dashboard page was eating 50MB of memory every time a user navigated to it and back. After three round trips, the tab was over 400MB and the UI was lagging. The component was unmounting, state was resetting, but something held onto memory and refused to let go.

Spotting the Leak

Open Chrome DevTools, go to the Memory tab, and select Allocation instrumentation on timeline. Navigate to the suspect page, navigate away, repeat 3-4 times, then stop recording.

If memory climbs with each cycle and never drops back, you have a leak. Blue bars that accumulate instead of disappearing on navigation are your visual signal.

Heap Snapshots: Finding What’s Leaking

Take three snapshots: before visiting the page, after visiting and leaving, then after visiting and leaving again. Compare Snapshot 3 against Snapshot 1, sorted by Size Delta. In my case: thousands of Detached HTMLDivElement nodes and growing EventListener counts.

The Culprit: Forgotten Subscriptions

The dashboard had a WebSocket connection. The cleanup looked correct but had a subtle bug:

// THE BUG
useEffect(() => {
  const ws = new WebSocket('wss://api.example.com/stream');

  ws.onmessage = (event) => {
    const data = JSON.parse(event.data);
    setMetrics(prev => [...prev, data]);
  };

  return () => {
    ws.close(); // This looks right...
  };
}, []);

The onmessage closure captured setMetrics. Even after ws.close(), the WebSocket wasn’t garbage collected because the closure held a reference chain: event handler -> state setter -> component fiber -> DOM nodes.

// FIXED
useEffect(() => {
  const ws = new WebSocket('wss://api.example.com/stream');

  const handleMessage = (event: MessageEvent) => {
    const data = JSON.parse(event.data);
    setMetrics(prev => [...prev, data]);
  };

  ws.addEventListener('message', handleMessage);

  return () => {
    ws.removeEventListener('message', handleMessage);
    ws.close();
  };
}, []);

Explicitly removing the listener before closing breaks the reference chain.

The Second Leak: setInterval Without Cleanup

// LEAK
useEffect(() => {
  if (!wsConnected) {
    setInterval(() => {
      fetchMetrics().then(data => setMetrics(data));
    }, 5000);
  }
}, [wsConnected]);

No cleanup. Every time wsConnected changed, a new interval was created and the old one kept running.

// FIXED
useEffect(() => {
  if (!wsConnected) {
    const intervalId = setInterval(() => {
      fetchMetrics().then(data => setMetrics(data));
    }, 5000);

    return () => clearInterval(intervalId);
  }
}, [wsConnected]);

The Third Leak: Closures Over Stale State

A ResizeObserver callback captured component state in its closure:

// LEAK — closure captures initial 'metrics', never updates
useEffect(() => {
  const observer = new ResizeObserver((entries) => {
    if (metrics.length > 0) {
      recalculateLayout(entries, metrics);
    }
  });

  observer.observe(containerRef.current);
  return () => observer.disconnect();
}, []);

The fix: use a ref for values that callbacks need without creating new closures:

const metricsRef = useRef(metrics);
metricsRef.current = metrics;

useEffect(() => {
  const observer = new ResizeObserver((entries) => {
    if (metricsRef.current.length > 0) {
      recalculateLayout(entries, metricsRef.current);
    }
  });

  if (containerRef.current) {
    observer.observe(containerRef.current);
  }
  return () => observer.disconnect();
}, []);

Prevention Checklist

For every useEffect:

  • Does it create a subscription, listener, observer, or interval? Cleanup must tear it down.
  • Does cleanup explicitly remove event listeners, not just close/disconnect?
  • Does the closure capture changing state? Use a ref instead.
  • Is there an async operation that might resolve after unmount? Use an AbortController.

Memory leaks in React almost always come from effects that set things up but don’t fully tear them down. Chrome DevTools makes them findable. Disciplined cleanup in every useEffect prevents them.

Adrian Saycon

Written by

Adrian Saycon

A developer with a passion for emerging technologies, Adrian Saycon focuses on transforming the latest tech trends into great, functional products.

Discussion (0)

Sign in to join the discussion

No comments yet. Be the first to share your thoughts.