Skip to main content

Command Palette

Search for a command to run...

Why Is My React App Crashing? Top 8 Memory Leak Causes

Learn: Why Is My React App Crashing? Top 8 Memory Leak Causes

Updated
11 min readView as Markdown
T

Welcome to TopperBlog! 👋

I'm a tech content creator passionate about helping developers level up their careers and master cutting-edge technologies.

🎯 What I Write About: • AI/ML Engineering & LLMs • Web3 & Blockchain Development
• System Design & Architecture • Interview Preparation (FAANG) • Freelancing & Remote Work • Modern Tech Stacks (Next.js, React, Rust, TypeScript) • Performance Optimization & Best Practices

💼 Mission: Sharing practical, actionable insights that accelerate your tech career and maximize your earning potential.

📚 15+ In-Depth Guides covering everything from earning $10k/month as a freelancer to cracking FAANG interviews.

🌐 Let's connect and grow together in this amazing tech journey!

#TechBlogger #SoftwareEngineering #CareerGrowth #WebDevelopment #AIEngineering

Why Is My React App Crashing? Top 8 Memory Leak Causes

You're Not Alone: When Your React App Becomes a Memory Monster

I'll never forget the panic I felt when my production React app started crashing every few hours. Users were complaining, my boss was breathing down my neck, and I had no idea what was wrong. The app worked fine in development, but in production? It was eating memory like a hungry teenager at an all-you-can-eat buffet.

After three sleepless nights and countless cups of coffee, I finally tracked down the culprit: a seemingly innocent setInterval that I'd forgotten to clean up. That experience taught me something valuable—React memory leaks are sneaky, common, and can turn your beautiful app into a performance nightmare.

If you're reading this, chances are you're dealing with similar frustration. Your React app is slowing down, crashing unexpectedly, or consuming memory like there's no tomorrow. Don't worry—I've been there, and I'm going to walk you through the eight most common memory leak causes I've encountered (and fixed) over the years.

Table of Contents

  1. Forgotten Event Listeners That Never Die
  2. Uncancelled Timers and Intervals
  3. Unsubscribed Observables and WebSocket Connections
  4. Closures Holding References to Large Objects
  5. Improper Use of useEffect Dependencies
  6. State Updates on Unmounted Components
  7. Infinite Loops in useEffect
  8. Memory-Heavy Third-Party Libraries

Main Content

1. Forgotten Event Listeners That Never Die

This is probably the most common memory leak I see in React applications. You add an event listener to the window or document object, but forget to remove it when your component unmounts. The result? Every time your component mounts and unmounts, you're adding more and more listeners that never get cleaned up.

The Problem:

function MyComponent() {
  useEffect(() => {
    const handleResize = () => {
      console.log('Window resized!');
    };

    window.addEventListener('resize', handleResize);
    // Oops! No cleanup function
  }, []);

  return <div>My Component</div>;
}

Every time this component mounts, a new event listener is attached. If users navigate back and forth between pages, you'll accumulate dozens or even hundreds of listeners, all consuming memory.

The Solution:

function MyComponent() {
  useEffect(() => {
    const handleResize = () => {
      console.log('Window resized!');
    };

    window.addEventListener('resize', handleResize);

    // Clean up the event listener
    return () => {
      window.removeEventListener('resize', handleResize);
    };
  }, []);

  return <div>My Component</div>;
}

Common Event Listeners to Watch:

Event TypeCommon Use CaseMemory Impact
scrollInfinite scroll, parallax effectsHigh (fires frequently)
resizeResponsive layoutsMedium
mousemoveCustom cursors, tooltipsVery High
keydown/keyupKeyboard shortcutsLow-Medium
click (on document)Click-outside detectionLow

2. Uncancelled Timers and Intervals

I learned this lesson the hard way when building a real-time dashboard. I had a setInterval fetching data every 5 seconds, and I didn't cancel it when the component unmounted. After an hour, my app had dozens of intervals running simultaneously, hammering my API and consuming memory.

The Problem:

function Dashboard() {
  const [data, setData] = useState(null);

  useEffect(() => {
    const intervalId = setInterval(() => {
      fetchData().then(setData);
    }, 5000);

    // Missing cleanup!
  }, []);

  return <div>{/* Dashboard content */}</div>;
}

The Solution:

function Dashboard() {
  const [data, setData] = useState(null);

  useEffect(() => {
    const intervalId = setInterval(() => {
      fetchData().then(setData);
    }, 5000);

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

  return <div>{/* Dashboard content */}</div>;
}

Pro tip: The same applies to setTimeout. Always clear your timers!

useEffect(() => {
  const timeoutId = setTimeout(() => {
    // Do something
  }, 3000);

  return () => clearTimeout(timeoutId);
}, []);

3. Unsubscribed Observables and WebSocket Connections

If you're using RxJS, WebSockets, or any subscription-based library, failing to unsubscribe is a guaranteed memory leak. I once debugged an app where WebSocket connections were piling up because the developer forgot to close them on unmount.

The Problem with RxJS:

function DataStream() {
  const [value, setValue] = useState(0);

  useEffect(() => {
    const subscription = dataObservable$.subscribe(data => {
      setValue(data);
    });

    // No unsubscribe!
  }, []);

  return <div>{value}</div>;
}

The Solution:

function DataStream() {
  const [value, setValue] = useState(0);

  useEffect(() => {
    const subscription = dataObservable$.subscribe(data => {
      setValue(data);
    });

    return () => {
      subscription.unsubscribe();
    };
  }, []);

  return <div>{value}</div>;
}

WebSocket Example:

function ChatComponent() {
  const [messages, setMessages] = useState([]);

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

    ws.onmessage = (event) => {
      setMessages(prev => [...prev, event.data]);
    };

    return () => {
      ws.close(); // Always close WebSocket connections!
    };
  }, []);

  return <div>{/* Chat UI */}</div>;
}

4. Closures Holding References to Large Objects

This one is subtle and took me ages to figure out the first time I encountered it. Closures in JavaScript can inadvertently hold references to large objects, preventing garbage collection.

The Problem:

function ImageGallery() {
  const [selectedImage, setSelectedImage] = useState(null);

  useEffect(() => {
    // Imagine this is a huge array of high-res images
    const largeImageData = fetchAllImages(); // 100MB of data

    const handleKeyPress = (e) => {
      if (e.key === 'Escape') {
        setSelectedImage(null);
        // The closure still holds a reference to largeImageData!
      }
    };

    window.addEventListener('keydown', handleKeyPress);

    return () => {
      window.removeEventListener('keydown', handleKeyPress);
    };
  }, []);

  return <div>{/* Gallery UI */}</div>;
}

The Solution:

function ImageGallery() {
  const [selectedImage, setSelectedImage] = useState(null);

  useEffect(() => {
    const handleKeyPress = (e) => {
      if (e.key === 'Escape') {
        setSelectedImage(null);
      }
    };

    window.addEventListener('keydown', handleKeyPress);

    return () => {
      window.removeEventListener('keydown', handleKeyPress);
    };
  }, []);

  // Fetch images only when needed, not in the effect
  const images = useMemo(() => fetchAllImages(), []);

  return <div>{/* Gallery UI */}</div>;
}

5. Improper Use of useEffect Dependencies

Missing dependencies in your useEffect array can cause stale closures and memory leaks. I've seen developers intentionally omit dependencies to avoid re-running effects, but this often backfires.

The Problem:

function UserProfile({ userId }) {
  const [userData, setUserData] = useState(null);

  useEffect(() => {
    fetchUser(userId).then(setUserData);
    // Missing userId in dependencies!
  }, []);

  return <div>{userData?.name}</div>;
}

When userId changes, the effect doesn't re-run, but the old closure still references the old userId. This can lead to stale data and memory issues.

The Solution:

function UserProfile({ userId }) {
  const [userData, setUserData] = useState(null);

  useEffect(() => {
    let cancelled = false;

    fetchUser(userId).then(data => {
      if (!cancelled) {
        setUserData(data);
      }
    });

    return () => {
      cancelled = true;
    };
  }, [userId]); // Include all dependencies!

  return <div>{userData?.name}</div>;
}

Dependency Array Comparison:

ApproachWhen to UseMemory Risk
[] (empty)One-time setup on mountLow (if cleaned up properly)
[dep1, dep2]Re-run when specific values changeLow (recommended)
No arrayRun on every renderHigh (avoid!)
Omitting dependenciesNever!Very High

6. State Updates on Unmounted Components

You've probably seen this warning in your console: "Can't perform a React state update on an unmounted component." This happens when an async operation completes after a component has unmounted, trying to update state that no longer exists.

The Problem:

function UserData() {
  const [user, setUser] = useState(null);

  useEffect(() => {
    fetchUser().then(data => {
      setUser(data); // What if component unmounted during fetch?
    });
  }, []);

  return <div>{user?.name}</div>;
}

The Solution (Using Cleanup Flag):

function UserData() {
  const [user, setUser] = useState(null);

  useEffect(() => {
    let isMounted = true;

    fetchUser().then(data => {
      if (isMounted) {
        setUser(data);
      }
    });

    return () => {
      isMounted = false;
    };
  }, []);

  return <div>{user?.name}</div>;
}

The Solution (Using AbortController):

function UserData() {
  const [user, setUser] = useState(null);

  useEffect(() => {
    const controller = new AbortController();

    fetch('/api/user', { signal: controller.signal })
      .then(res => res.json())
      .then(data => setUser(data))
      .catch(err => {
        if (err.name !== 'AbortError') {
          console.error(err);
        }
      });

    return () => {
      controller.abort();
    };
  }, []);

  return <div>{user?.name}</div>;
}

7. Infinite Loops in useEffect

This is a nightmare scenario I've debugged more times than I'd like to admit. An infinite loop in useEffect will crash your app faster than you can say "memory leak."

The Problem:

function Counter() {
  const [count, setCount] = useState(0);
  const [data, setData] = useState({});

  useEffect(() => {
    setData({ count }); // Creates a new object every time
  }, [data]); // data changes every render = infinite loop!

  return <div>{count}</div>;
}

Another Common Mistake:

function SearchComponent() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);

  useEffect(() => {
    if (query) {
      search(query).then(setResults);
    }
  }, [query, results]); // Including results causes infinite loop!

  return <div>{/* Search UI */}</div>;
}

The Solution:

function SearchComponent() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);

  useEffect(() => {
    if (query) {
      search(query).then(setResults);
    }
  }, [query]); // Only depend on query!

  return <div>{/* Search UI */}</div>;
}

Pro tip: Use useMemo or useCallback for object/function dependencies:

function DataComponent() {
  const [count, setCount] = useState(0);

  const config = useMemo(() => ({
    count,
    timestamp: Date.now()
  }), [count]);

  useEffect(() => {
    processData(config);
  }, [config]); // Safe now!

  return <div>{count}</div>;
}

8. Memory-Heavy Third-Party Libraries

Not all memory leaks are your fault! Some third-party libraries don't clean up after themselves, or they're just memory-intensive by design. I once used a charting library that cached every data point ever rendered, causing my app to balloon to over 500MB after a few hours.

Common Culprits:

Library TypeMemory RiskWhat to Watch
Charting librariesHighData caching, canvas elements
Rich text editorsVery HighDOM nodes, undo history
Animation librariesMediumTweens, timelines
Data visualizationHighLarge datasets, SVG elements
Video playersVery HighBuffers, streams

How to Identify:

function ChartComponent({ data }) {
  const chartRef = useRef(null);

  useEffect(() => {
    const chart = new HeavyChartLibrary(chartRef.current, {
      data,
      // ... options
    });

    // Always check library docs for cleanup methods!
    return () => {
      if (chart.destroy) {
        chart.destroy();
      }
      // Some libraries use different methods:
      // chart.dispose(), chart.clear(), chart.remove()
    };
  }, [data]);

  return <div ref={chartRef} />;
}

My Debugging Checklist for Third-Party Libraries:

  1. Check the library's GitHub issues for "memory leak"
  2. Look for destroy(), dispose(), or cleanup() methods in the docs
  3. Use Chrome DevTools Memory Profiler to identify the culprit
  4. Consider lighter alternatives if the library is problematic

Real Example with Chart.js:

import { Chart } from 'chart.js';

function MyChart({ data }) {
  const canvasRef = useRef(null);
  const chartRef = useRef(null);

  useEffect(() => {
    const ctx = canvasRef.current.getContext('2d');

    chartRef.current = new Chart(ctx, {
      type: 'line',
      data: data,
    });

    return () => {
      // Chart.js requires explicit cleanup
      if (chartRef.current) {
        chartRef.current.destroy();
      }
    };
  }, [data]);

  return <canvas ref={canvasRef} />;
}

How to Debug Memory Leaks in Your React App

Before we wrap up, let me share my go-to debugging process. When I suspect a memory leak, here's what I do:

Step 1: Use Chrome DevTools Memory Profiler

  1. Open DevTools → Memory tab
  2. Take a heap snapshot
  3. Interact with your app (navigate, open/close components)
  4. Take another snapshot
  5. Compare snapshots to see what's growing

Step 2: React DevTools Profiler

The React DevTools Profiler can help you identify components that are re-rendering unnecessarily, which can contribute to memory issues.

Step 3: Add Console Logs

Sometimes old-school debugging works best:

useEffect(() => {
  console.log('Component mounted');

  return () => {
    console.log('Component unmounted - cleanup running');
  };
}, []);

If you don't see the unmount log, your cleanup isn't running!

Step 4: Use the why-did-you-render Library

This library helps identify unnecessary re-renders:

import whyDidYouRender from '@welldone-software/why-did-you-render';

if (process.env.NODE_ENV === 'development') {
  whyDidYouRender(React, {
    trackAllPureComponents: true,
  });
}

FAQ

Q: How do I know if my React app has a memory leak?

A: Watch for these signs: the app slows down over time, crashes after extended use, browser tab memory usage keeps growing (check Task Manager), or you see "out of memory" errors. Use Chrome DevTools Memory Profiler to confirm by taking heap snapshots before and after using your app.

Q: Can memory leaks happen in production but not development?

A: Absolutely! Development mode often has shorter sessions and hot reloading that masks leaks. Production apps run longer, handle more users, and don't get the "fresh start" that hot reloading provides. Always test your app in production mode before deploying.

Q: Do I need to clean up useState and useContext?

A: No, React automatically cleans up state and context when components unmount. You only need to clean up external subscriptions, event listeners, timers, and third-party library instances that React doesn't manage.

Q: What's the difference between a memory leak and high memory usage?

A: High memory usage means your app legitimately needs a lot of memory (like displaying 10,000 images). A memory leak means memory that should be freed isn't being released, causing usage to grow indefinitely over time. Leaks always get worse; legitimate high usage stays relatively constant.

Q: Will React 18's automatic batching help with memory leaks?

A: Not directly. Automatic batching reduces unnecessary re-renders, which can improve performance, but it won't fix memory leaks caused by uncleaned event listeners, timers, or subscriptions. You still need proper cleanup functions in your useEffect hooks.

Key Takeaways

  • Always return cleanup functions from useEffect hooks that add event listeners, timers, or subscriptions
  • Remove event listeners attached to window, document, or DOM elements when components unmount
  • Clear all timers and intervals using clearTimeout() and clearInterval() in cleanup functions
  • Unsubscribe from observables and close WebSocket connections before component unmount
  • Include all dependencies in your useEffect dependency array to avoid stale closures
  • Prevent state updates on unmounted components using cleanup flags or AbortController
  • Watch for infinite loops caused by objects or arrays in dependency arrays—use useMemo/useCallback
  • Check third-party library documentation for proper cleanup methods (destroy, dispose, clear)
  • Use Chrome DevTools Memory Profiler to identify and confirm memory leaks in your application
  • Test in production mode because memory leaks often only appear during extended usage

Conclusion: Your React App Doesn't Have to Be a Memory Monster

Remember that panic I mentioned at the beginning? After fixing that forgotten setInterval, I made myself a promise:

Why Is My React App Crashing? Top 8 Memory Leak Causes