Last week, one of our production dashboards started slowing down after about 20 minutes of use. It wasn’t a sudden crash, but a slow, painful death of responsiveness. Here is how I tracked it down to a single useEffect hook.
The Symptoms
Users reported that switching between tabs in our analytics dashboard eventually made the browser unresponsive. A quick look at the Chrome Task Manager showed memory usage climbing steadily: 200MB, 500MB, 1.2GB…
The Investigation
I used the Chrome Performance Profiler to record a session. The heap snapshot revealed thousands of detached DOM nodes and event listeners that weren’t being garbage collected.
The Culprit
I narrowed it down to a real-time data widget component. Here is the simplified problematic code:
useEffect(() => {
const connection = createWebSocketConnection();
connection.on('data', (data) => {
setData(data);
});
}, []);
See the issue? The connection was established, but never closed when the component unmounted. Every time the user navigated away and back, a NEW connection was created, leaving the old one alive and holding onto references.
The Fix
The solution was simple but crucial: adding a cleanup function.
useEffect(() => {
const connection = createWebSocketConnection();
connection.on('data', (data) => {
setData(data);
});
// The Cleanup Function
return () => {
connection.disconnect();
};
}, []);
Lessons Learned
1. Always think about lifecycle: When you create a subscription, timer, or listener in useEffect, immediately write the cleanup return.
2. Tools are your friend: The Memory tab in Chrome DevTools is intimidating but essential for these kinds of bugs.
3. SPA routing: In Single Page Applications, “navigating away” doesn’t reset the page state. Components mount and unmount, and your code needs to handle that gracefully.