Fix React useEffect Memory Bloat in 12 Minutes

Stop memory leaks from useEffect hooks in React 19. Learn proper cleanup, dependency management, and debugging techniques.

Problem: Your React App's Memory Usage Keeps Growing

Your React app starts at 50MB but balloons to 300MB+ after a few minutes of use. Components unmount but memory never gets released, and eventually the browser tab crashes.

You'll learn:

  • Why useEffect creates memory leaks
  • How to properly cleanup subscriptions and timers
  • Tools to identify which hooks are leaking

Time: 12 min | Level: Intermediate


Why This Happens

React's useEffect runs side effects (API calls, subscriptions, timers) that persist after component unmount unless explicitly cleaned up. Every re-render can create new listeners without removing old ones.

Common symptoms:

  • Memory usage climbs steadily in DevTools
  • Browser warns "Page is using significant memory"
  • Event listeners multiply (check with getEventListeners(window))
  • Intervals/timers continue after component unmounts

Solution

Step 1: Add Cleanup Functions to All useEffects

Every useEffect with subscriptions needs a return function:

useEffect(() => {
  // ✅ Subscribe to event
  const handleResize = () => setWidth(window.innerWidth);
  window.addEventListener('resize', handleResize);
  
  // ✅ Return cleanup function
  return () => {
    window.removeEventListener('resize', handleResize);
  };
}, []); // Empty deps = run once on mount

Why this works: React calls the cleanup function before re-running the effect and when the component unmounts, removing the listener.

If it fails:

  • Event listener not removed: Make sure you're removing the exact same function reference (no inline functions)
  • Cleanup not running: Check React DevTools Profiler to verify unmount is happening

Step 2: Clear All Timers and Intervals

Timers continue running even after unmount:

useEffect(() => {
  // ⌠Bad: Interval leaks memory
  setInterval(() => {
    fetchData();
  }, 5000);
}, []);

// ✅ Good: Cleanup clears interval
useEffect(() => {
  const intervalId = setInterval(() => {
    fetchData();
  }, 5000);
  
  return () => clearInterval(intervalId);
}, []);

Why this works: clearInterval stops the timer and allows garbage collection of any closures.

Common mistake: Using setTimeout in a dependency array that triggers constantly:

// ⌠Creates new timeout on every render
useEffect(() => {
  setTimeout(() => console.log(data), 1000);
}, [data]); // Runs every time data changes

// ✅ Cleanup prevents stacking timeouts
useEffect(() => {
  const timeoutId = setTimeout(() => console.log(data), 1000);
  return () => clearTimeout(timeoutId);
}, [data]);

Step 3: Cancel Fetch Requests with AbortController

Fetch requests in unmounted components can cause memory leaks:

useEffect(() => {
  // ✅ Create abort controller
  const abortController = new AbortController();
  
  async function loadData() {
    try {
      const response = await fetch('/api/data', {
        signal: abortController.signal // Pass signal to fetch
      });
      const data = await response.json();
      setData(data);
    } catch (error) {
      if (error.name === 'AbortError') {
        // Request was cancelled, this is expected
        return;
      }
      console.error('Fetch failed:', error);
    }
  }
  
  loadData();
  
  // ✅ Cancel on unmount
  return () => abortController.abort();
}, []);

Why this works: Aborting the fetch stops processing and prevents setState on unmounted components.

If it fails:

  • AbortError in console: This is normal, you can ignore or handle it in the catch block
  • Still leaking: Check if you have multiple fetches without abort controllers

Step 4: Fix WebSocket and EventSource Connections

Persistent connections must be closed:

useEffect(() => {
  const ws = new WebSocket('wss://api.example.com');
  
  ws.onmessage = (event) => {
    setMessages(prev => [...prev, event.data]);
  };
  
  ws.onerror = (error) => {
    console.error('WebSocket error:', error);
  };
  
  // ✅ Close connection on unmount
  return () => {
    if (ws.readyState === WebSocket.OPEN) {
      ws.close();
    }
  };
}, []);

Why this works: ws.close() terminates the connection and removes all event listeners automatically.


Step 5: Identify Leaks with Chrome DevTools

Find which components are leaking:

# 1. Open Chrome DevTools (F12)
# 2. Go to Memory tab
# 3. Take a heap snapshot
# 4. Interact with your app (navigate, open/close components)
# 5. Take another snapshot
# 6. Compare snapshots - look for "Detached" DOM nodes

You should see: Components with cleanup functions should not appear as "Detached" or have growing "Retained Size" values.

Look for:

  • Detached HTMLDivElement nodes (unmounted components still in memory)
  • (closure) entries with large retained sizes (event handlers not cleaned up)
  • Growing arrays in snapshots (subscriptions accumulating)

Verification

Test Memory Cleanup

// Add this to your component for testing
useEffect(() => {
  console.log('Component mounted');
  
  return () => {
    console.log('Component cleanup ran');
  };
}, []);

You should see: "Component cleanup ran" in console when navigating away. If you don't see it, the component isn't unmounting properly (check your router/parent component).

Measure Memory Usage

// Run in browser console after interacting with your app
performance.memory.usedJSHeapSize / 1048576 + ' MB'

Expected: Memory should stabilize or decrease after navigating back to pages you've already visited. If it keeps climbing, you still have leaks.


What You Learned

  • Every useEffect with side effects needs a cleanup function
  • Timers, listeners, and fetch requests all require explicit cleanup
  • Chrome DevTools Memory tab shows exactly what's leaking
  • AbortController is the modern way to cancel fetch requests

Limitation: React StrictMode in development runs effects twice, making it look like leaks. This is intentional and doesn't happen in production.

Common misconception: "Empty dependency array means it only runs once" - true, but cleanup still runs on unmount.


Quick Reference: Cleanup Patterns

// Event listeners
useEffect(() => {
  const handler = () => {};
  window.addEventListener('event', handler);
  return () => window.removeEventListener('event', handler);
}, []);

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

// Fetch
useEffect(() => {
  const ac = new AbortController();
  fetch(url, { signal: ac.signal });
  return () => ac.abort();
}, []);

// WebSockets
useEffect(() => {
  const ws = new WebSocket(url);
  return () => ws.close();
}, []);

// Observers
useEffect(() => {
  const observer = new IntersectionObserver(() => {});
  observer.observe(element);
  return () => observer.disconnect();
}, []);

Tested on React 19.0.2, Chrome 131, Node.js 22.x, macOS & Windows