Partner with CodeWalnut to drive your digital growth!

Tell us about yourself and we will show you how technology can help drive business growth.

Thank you for your interest in CodeWalnut.Our digital expert will reach you within 24-48 hours.
Oops! Something went wrong while submitting the form.
Insights

5 React Memory Leaks That Kill Performance (Fix Them Now)

Stop React memory leaks before they crash your app. Learn to fix setTimeout, event listeners, WebSockets & async operations with real production examples.

Overview

Your React app is bleeding memory. Users complain about sluggish performance after using your dashboard for an hour. Browser tabs crash during peak usage. Sound familiar?

I've debugged this exact scenario more times than I care to count. Memory leaks in React are silent killers—they don't throw errors, they don't fail tests, but they'll tank your application's performance when it matters most.

What Is a Memory Leak?

Picture this: Your app allocates memory for a WebSocket connection. The user navigates away, the component unmounts, but that connection stays open. The memory never gets released. Multiply this by hundreds of user interactions, and you've got a problem.

A memory leak happens when your application holds references to objects that should have been garbage collected. In React, this typically occurs when side effects—timers, event listeners, async operations—outlive their components.

Why Memory Leaks Happen in React

React's component lifecycle creates a perfect storm for memory leaks. Components mount and unmount constantly, but JavaScript doesn't clean up after itself automatically.

Consider these common culprits:

  • Timers (setTimeout, setInterval) that keep ticking after unmount
  • Event listeners on window or document that never get removed
  • WebSocket connections or subscriptions that stay open
  • Async operations that try to update unmounted components

Each of these creates a reference that prevents garbage collection. The result? Memory usage climbs until your app becomes unusable.

Impact of Memory Leaks

There are multiple examples of trading dashboards crashing after 2-3 hours of active use. The cause? Dozens of uncleaned WebSocket subscriptions consuming 2GB of memory. This happens more often than developers realize.

Impact Area Consequence
🧠 Browser Memory RAM usage grows from 50MB to 500MB+ over time
🐢 Performance UI freezes during state updates, animations stutter
💥 Crashes Browser tabs crash during critical user workflows
🐛 Bugs Stale closures cause incorrect data to display

5 Common Memory Leaks in React (And Their Solutions)

1. ⏲️ setTimeout / setInterval Without Cleanup

The Problem: You set a timer in useEffect, then the user navigates away. The timer keeps running, trying to update a component that no longer exists.

jsx

  function LeakyComponent() {
  const [data, setData] = useState(null);
  
  useEffect(() => {
    const timer = setTimeout(() => {
      // This will run even after unmount, causing a memory leak
      setData("Updated after 5 seconds");
    }, 5000);
  }, []);

  return <div>Timer Component: {data}</div>;
}

Example: I've seen this in notification systems where developers set timers to auto-dismiss alerts. Users close the modal, but the timer keeps the component reference alive.

✅ The Fix:

jsx

function SafeComponent() {
  const [data, setData] = useState(null);
  
  useEffect(() => {
    const timer = setTimeout(() => {
      setData("Safely updated!");
    }, 5000);

    // Always clean up your timers
    return () => clearTimeout(timer);
  }, []);

  return <div>Safe Timer Component: {data}</div>;
}

2. 👂 Unremoved Event Listeners

The Problem: You add a global event listener but forget to remove it. Every time the component mounts, you add another listener. After 50 route changes, you have 50 listeners doing the same work.

jsx

function LeakyComponent() {
  useEffect(() => {
    // Anonymous function = impossible to remove later
    window.addEventListener("resize", () => {
      console.log("Window resized");
    });
  }, []);

  return <div>Window Listener</div>;
}


Example: An e-commerce site had a product image zoom component. Every product page visit added another scroll listener. After browsing 20 products, scrolling became unbearably slow.

✅ The Fix:

jsx

function SafeComponent() {
  useEffect(() => {
    const handleResize = () => {
      console.log("Window resized safely");
    };
    
    window.addEventListener("resize", handleResize);

    // Remove the exact same function reference
    return () => window.removeEventListener("resize", handleResize);
  }, []);

  return <div>Safe Resize Listener</div>;
}

3. 🌐 WebSocket or Subscription Not Closed

The Problem: You open a WebSocket (RxJS, or other custom subscriptions), and it stays open in a connected state, or you subscribe to a data stream, but never close it. The connection stays active indefinitely, consuming memory and bandwidth.

jsx

function LeakyComponent() {
  const [messages, setMessages] = useState([]);
  
  useEffect(() => {
    const socket = new WebSocket("wss://api.example.com/live");
    
    socket.onmessage = (event) => {
      const message = JSON.parse(event.data);
      setMessages(prev => [...prev, message]);
    };
    
    // Socket stays open forever - memory leak!
  }, []);

  return <div>Live Messages: {messages.length}</div>;
}

Example: Let’s say there is a financial dashboard that has real-time price feeds. Users would navigate between different asset pages, but each WebSocket stays connected. After 30 minutes, the app will handle 100+ simultaneous connections.

✅ The Fix:

jsx

function SafeComponent() {
  const [messages, setMessages] = useState([]);
  
  useEffect(() => {
    const socket = new WebSocket("wss://api.example.com/live");
    
    socket.onmessage = (event) => {
      const message = JSON.parse(event.data);
      setMessages(prev => [...prev, message]);
    };

    // Always close your connections
    return () => {
      socket.close();
    };
  }, []);

  return <div>Live Messages: {messages.length}</div>;
}

4. 🧵 Async Operations Updating Unmounted Components

The Problem: You start an async operation, the user navigates away, but your promise still tries to update state. React will warn you about this, but the memory reference remains.

jsx

function LeakyComponent() {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  
  useEffect(() => {
    fetch("/api/slow-endpoint")
      .then(response => response.json())
      .then(result => {
        // Component might be unmounted by now
        setData(result);
        setLoading(false);
      });
  }, []);

  return <div>{loading ? "Loading..." : data?.title}</div>;
}

Example: An admin panel with a slow user search feature. Users would type a query, get impatient, navigate away, but the search request would complete and try to update the unmounted component. With hundreds of these hanging requests, memory usage will spiral.

✅ The Fix (Option 1 - Mounted Flag):

jsx

function SafeComponent() {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  
  useEffect(() => {
    let isMounted = true;
    
    fetch("/api/slow-endpoint")
      .then(response => response.json())
      .then(result => {
        // Only update if component is still mounted
        if (isMounted) {
          setData(result);
          setLoading(false);
        }
      });

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

  return <div>{loading ? "Loading..." : data?.title}</div>;
}

✅ Better Fix (Option 2 - AbortController):

jsx

function SafeComponent() {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  
  useEffect(() => {
    const controller = new AbortController();
    
    fetch("/api/slow-endpoint", {
      signal: controller.signal
    })
      .then(response => response.json())
      .then(result => {
        setData(result);
        setLoading(false);
      })
      .catch(error => {
        if (error.name !== 'AbortError') {
          console.error('Fetch failed:', error);
        }
      });

    // Cancel the request if component unmounts
    return () => controller.abort();
  }, []);

  return <div>{loading ? "Loading..." : data?.title}</div>;
}

5. 🧳 Closures Retaining Large Objects

The Problem: Your event handler closes over a large data structure. Even after the data is no longer needed, the closure keeps it alive in memory.

jsx

function LeakyComponent() {
  // Large dataset - imagine this is 10MB of user records
  const bigUserData = useMemo(() => 
    new Array(100000).fill(null).map((_, i) => ({
      id: i,
      name: `User ${i}`,
      details: new Array(100).fill(`Detail ${i}`)
    }))
  , []);

  const handleExport = () => {
    // This closure captures bigUserData forever
    console.log(`Exporting ${bigUserData.length} users`);
  };

  useEffect(() => {
    // Event listener keeps the entire closure (and bigUserData) alive
    document.addEventListener("keydown", handleExport);
  }, [handleExport]);

  return <div>User Management Dashboard</div>;
}

Example: A CRM system where the contact list component loads 50,000 records. The export function closes over this data, and the keyboard shortcut event listener keeps everything in memory even when viewing other pages.

✅ The Fix:

jsx

function SafeComponent() {
  const [userCount, setUserCount] = useState(0);
  
  useEffect(() => {
    // Keep large data scoped to the effect
    const bigUserData = new Array(100000).fill(null).map((_, i) => ({
      id: i,
      name: `User ${i}`,
      details: new Array(100).fill(`Detail ${i}`)
    }));
    
    setUserCount(bigUserData.length);
    
    const handleExport = () => {
      // Only access data that's actually needed
      console.log(`Exporting ${userCount} users`);
    };

    document.addEventListener("keydown", handleExport);
    
    // Clean up the listener and let bigUserData be garbage collected
    return () => document.removeEventListener("keydown", handleExport);
  }, [userCount]);

  return <div>User Management Dashboard ({userCount} users)</div>;
}

Tools to Detect Memory Leaks

Here are the tools I use when hunting memory leaks:

Tool Use Case
🔍 Chrome DevTools → Memory tab Take heap snapshots, compare object counts between snapshots
📦 React Profiler Identify components that re-render frequently or retain references
🧪 Why Did You Render Catch unnecessary re-renders that might indicate stale closures
🚨 eslint-plugin-react-hooks Catch missing dependencies and incorrect effect usage

Pro tip: Record heap snapshots before and after user interactions. Growing object counts indicate potential leaks.

Best Practices to Avoid Memory Leaks in React

After debugging hundreds of React apps, these practices will save you hours of frustration:

  1. Always return cleanup functions in useEffect - If you create it, clean it up. No exceptions.
  2. Use AbortController for fetch requests - Don't rely on mounted flags when you can cancel requests properly.
  3. Keep closures lean - Don't close over large objects unless absolutely necessary.
  4. Remove global event listeners - window and document listeners are the most common sources of leaks.
  5. Close subscriptions and connections - WebSockets, intervals, and third-party library subscriptions need explicit cleanup.
  6. Use ESLint rules - eslint-plugin-react-hooks catches many issues before they reach production.

Conclusion

Memory leaks in React aren't always obvious, but they're costly when left unchecked.

I've seen crashes during product demos, frozen trading platforms during market hours, and dashboards that became unusable after extended sessions.

Each time, the root cause traced back to the same patterns we've covered.

Modern React applications push the boundaries of what's possible in the browser.

With this complexity comes responsibility—effects, subscriptions, and async operations don't clean themselves up.

The practices I've shared above will help you:

  • Prevent unnecessary memory usage
  • Build smoother, more reliable React applications
  • Save hours of debugging in production

Implement these cleanup patterns consistently, and your React applications will perform reliably under real-world usage.

Your users won't notice when you prevent memory leaks, but they'll definitely notice when you don't.

Author
Author
Umesh
Umesh
Senior Software Engineer