Why Is My React App Crashing? Top 8 Memory Leak Causes
Learn: Why Is My React App Crashing? Top 8 Memory Leak Causes
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
- Forgotten Event Listeners That Never Die
- Uncancelled Timers and Intervals
- Unsubscribed Observables and WebSocket Connections
- Closures Holding References to Large Objects
- Improper Use of useEffect Dependencies
- State Updates on Unmounted Components
- Infinite Loops in useEffect
- 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 Type | Common Use Case | Memory Impact |
scroll | Infinite scroll, parallax effects | High (fires frequently) |
resize | Responsive layouts | Medium |
mousemove | Custom cursors, tooltips | Very High |
keydown/keyup | Keyboard shortcuts | Low-Medium |
click (on document) | Click-outside detection | Low |
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:
| Approach | When to Use | Memory Risk |
[] (empty) | One-time setup on mount | Low (if cleaned up properly) |
[dep1, dep2] | Re-run when specific values change | Low (recommended) |
| No array | Run on every render | High (avoid!) |
| Omitting dependencies | Never! | 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 Type | Memory Risk | What to Watch |
| Charting libraries | High | Data caching, canvas elements |
| Rich text editors | Very High | DOM nodes, undo history |
| Animation libraries | Medium | Tweens, timelines |
| Data visualization | High | Large datasets, SVG elements |
| Video players | Very High | Buffers, 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:
- Check the library's GitHub issues for "memory leak"
- Look for
destroy(),dispose(), orcleanup()methods in the docs - Use Chrome DevTools Memory Profiler to identify the culprit
- 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
- Open DevTools → Memory tab
- Take a heap snapshot
- Interact with your app (navigate, open/close components)
- Take another snapshot
- 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: