Finding Memory Leaks: Why Your App Gets Slower Over Time
The 5-Minute Performance Cliff
I built a dashboard page. It ran smoothly at first. Then users started complaining: "After about 10 minutes, it becomes unbearably slow." Refreshing the page made it fast again. Something was accumulating.
I thought it was a React rendering issue. Opened React DevTools Profiler and checked render times. Everything looked fine. Then I suspected too many API calls. Opened the Network tab. That wasn't it either. Finally, I opened the Memory tab and saw something shocking.
Memory usage was climbing continuously. Started at 30MB on load, exceeded 150MB after 10 minutes. The Garbage Collector couldn't keep up. There was a memory leak somewhere in my code.
The Leaky Bucket Metaphor
The easiest way to understand memory leaks is to think of a water tank. A healthy app has balanced input and output. JavaScript's Garbage Collector automatically empties unused memory.
But when there's a hole, things break. Not that water leaks out, but the opposite happens. Water can't drain and keeps accumulating. That's a memory leak. Data that's no longer needed, but something holds a reference to it, preventing the Garbage Collector from cleaning it up.
In my case, the dashboard received real-time data. A WebSocket connection sent updates every second. When users navigated away, the WebSocket stayed alive and event listeners kept piling up. Water kept flowing in, but the drain was clogged.
Finding the Culprit with Chrome DevTools Memory Tab
When you first open the Memory tab, you see three options:
- Heap snapshot: Takes a photograph of current memory state
- Allocation timeline: Shows memory allocation over time
- Allocation sampling: Tracks memory usage by function
I started with Heap snapshot. Took one snapshot right after page load, used the app for 5 minutes, then took a second snapshot. Then compared them.
// The code causing memory leaks
function DashboardWidget() {
useEffect(() => {
const ws = new WebSocket('wss://api.example.com/live');
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
updateChart(data); // This function kept adding event listeners
};
// No cleanup!
// return () => ws.close();
}, []);
return <Chart />;
}
In the Heap snapshot's Comparison view, you see objects that increased between snapshots. In my case, hundreds of EventListener objects had accumulated. The Retained Size showed each took several KB, totaling dozens of MB combined.
Common Memory Leak Patterns
After finding the culprit, I saw the pattern. Beyond what I experienced, there are common cases.
1. Forgotten Event Listeners
// Bad
function BadComponent() {
useEffect(() => {
window.addEventListener('resize', handleResize);
// No cleanup
}, []);
}
// Good
function GoodComponent() {
useEffect(() => {
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
}
This is the most common. Event listeners persist in memory from the moment you register them. Even when the component unmounts, the listener remains, holding onto all data it references.
2. Timers Not Cleared
// Bad
function PollingComponent() {
useEffect(() => {
const interval = setInterval(() => {
fetchData();
}, 1000);
// No cleanup
}, []);
}
// Good
function PollingComponent() {
useEffect(() => {
const interval = setInterval(() => {
fetchData();
}, 1000);
return () => clearInterval(interval);
}, []);
}
Same goes for setInterval and setTimeout. If the timer lives on, the callback function stays in memory along with everything it references.
3. Unsubscribed Subscriptions
// Bad
function SubscriptionComponent() {
useEffect(() => {
const subscription = dataStream.subscribe(data => {
setState(data);
});
// No cleanup
}, []);
}
// Good
function SubscriptionComponent() {
useEffect(() => {
const subscription = dataStream.subscribe(data => {
setState(data);
});
return () => subscription.unsubscribe();
}, []);
}
This happens frequently with RxJS or event streams. Always unsubscribe what you subscribe to.
4. Uncanceled Fetch Requests
// Bad
function SearchComponent() {
useEffect(() => {
fetch(`/api/search?q=${query}`)
.then(res => res.json())
.then(data => setResults(data));
}, [query]);
}
// Good
function SearchComponent() {
useEffect(() => {
const controller = new AbortController();
fetch(`/api/search?q=${query}`, { signal: controller.signal })
.then(res => res.json())
.then(data => setResults(data))
.catch(err => {
if (err.name === 'AbortError') return;
console.error(err);
});
return () => controller.abort();
}, [query]);
}
When users type quickly, multiple fetch requests fire simultaneously. Previous requests should be canceled, but if they're not, they accumulate in memory.
My Debugging Workflow
Now when I suspect a memory leak, I follow this process:
- Open Chrome DevTools: Go to Memory tab
- Take first snapshot: Right after page load
- Use the app: Repeat suspected actions (navigate pages, open/close modals, etc.)
- Take second snapshot: After 5-10 minutes
- Check Comparison view: See what increased
- Check Retained Size: Find objects taking most space
- Trace Retainers: See what's holding these objects
Retainers show the reference chain. "This object is referenced by A, A is referenced by B..." Following the chain eventually leads to my code.
In my case, the chain was WebSocket → onmessage handler → updateChart function → Chart instance. Because the WebSocket wasn't closed, the handler stayed alive, and the handler referenced the Chart, keeping all its data in memory.
React Checklist
Here are prevention patterns I learned using React.
useEffect Cleanup is Essential
function Component() {
useEffect(() => {
// setup
const resource = createResource();
// cleanup (always include)
return () => {
resource.cleanup();
};
}, []);
}
Not every useEffect needs cleanup, but when touching external resources, it's essential. WebSockets, EventListeners, Timers, Subscriptions, etc.
AbortController is Default
function useData(url) {
const [data, setData] = useState(null);
useEffect(() => {
const controller = new AbortController();
fetch(url, { signal: controller.signal })
.then(res => res.json())
.then(setData)
.catch(err => {
if (err.name !== 'AbortError') {
console.error(err);
}
});
return () => controller.abort();
}, [url]);
return data;
}
I've made it a habit to create an AbortController with every fetch. When the component unmounts, the request cancels, and even if a response arrives, setState won't run.
Watch Out for Closures
// Bad: Capturing large objects in closures
function Component({ bigData }) {
const handleClick = () => {
console.log('clicked');
// bigData isn't used but gets captured by closure
};
useEffect(() => {
document.addEventListener('click', handleClick);
return () => document.removeEventListener('click', handleClick);
}, []);
}
// Good: Extract only what's needed
function Component({ bigData }) {
const id = bigData.id; // Only what's needed
const handleClick = useCallback(() => {
console.log('clicked', id);
}, [id]);
useEffect(() => {
document.addEventListener('click', handleClick);
return () => document.removeEventListener('click', handleClick);
}, [handleClick]);
}
Closures are convenient but dangerous. When creating functions, they capture all variables in scope. Capturing large objects whole keeps them in memory indefinitely.
Bottom Line
Memory leaks are invisible, making them hard to find. But Chrome DevTools Memory tab makes them visible. Taking and comparing Heap snapshots reveals what's accumulating.
Most leaks happen from missing cleanup. Event listeners, timers, subscriptions, fetch requests—when touching external resources, always clean up. In React, useEffect cleanup handles this.
My dashboard was fixed by adding WebSocket cleanup. Memory usage stabilized at 30MB. User feedback changed to "It doesn't slow down anymore."
Once you experience a memory leak, you see the pattern. After that, you can prevent them. Make cleanup a habit and open the Memory tab when suspicious.