
Finding Memory Leaks: Why Your App Gets Slower Over Time
Your app starts fast but slows to a crawl after 10 minutes. Learn to find and fix memory leaks using Chrome DevTools Memory tab.

Your app starts fast but slows to a crawl after 10 minutes. Learn to find and fix memory leaks using Chrome DevTools Memory tab.
Why does my server crash? OS's desperate struggle to manage limited memory. War against Fragmentation.

Class is there, but style is missing? Debugging Tailwind CSS like a detective.

Think Android is easier than iOS? Meet Gradle Hell. Learn to fix minSdkVersion conflicts, Multidex limit errors, Namespace issues in Gradle 8.0, and master dependency analysis with `./gradlew dependencies`.

If Paging is mechanical chopping, Segmentation is meaningful organization. Managing by Code, Data, Stack.

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 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.
When you first open the Memory tab, you see three options:
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.
After finding the culprit, I saw the pattern. Beyond what I experienced, there are common cases.
// 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.
// 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.
// 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.
// 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.
Now when I suspect a memory leak, I follow this process:
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.
Here are prevention patterns I learned using React.
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.
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.
// 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.
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.