Blog #8: Memory Trash – The price of forgetting to 'clean up' your Event Listeners
The story of an app that crashed after 30 minutes of use, and the journey to track down the silent killer known as Memory Leak.
I once worked on a project building a web-based Drawing Tool. Users could drag and drop shapes, resize them, and draw freely. Everything was great until the QA department sent me a report: "The app runs very smoothly at first, but after about 30 minutes of continuous dragging and dropping, the browser starts to lag and finally... crashes (Out of Memory)."
I opened Chrome Task Manager and witnessed a horrifying sight: my app tab was consuming 4GB of RAM and the number continued to increase every time I clicked the mouse.
I knew I had just encountered the scariest "monster" in JavaScript programming: the Memory Leak.
1. The Silent Killer: Orphaned References
In JavaScript, the Garbage Collector is very smart; it will delete objects that are no longer in use. But it only does so when that object no longer has any links (references).
When you write window.addEventListener('mousemove', handleMove), you've just created a permanent link between the global window object and the handleMove function inside your component. Even when that component is removed from the screen (unmount), window still holds that function. And because that function is inside the component, it holds all variables, state, and child components... Result? The entire old component tree still sits in memory, unable to be deleted.
2. The Problem: A crack from a drag-and-drop effect
In my drawing tool, every time a user selects a shape, I listen for mouse move events to update the position.
// My elementary mistake
useEffect(() => {
const handleMouseMove = (e) => {
// Update position logic...
console.log('Moving...');
};
window.addEventListener('mousemove', handleMouseMove);
// Forgot: return () => window.removeEventListener('mousemove', handleMouseMove);
}, [selectedId]);
Every time the user selects a new shape (selectedId changes), a new Listener is added. After 100 selections, there are 100 handleMouseMove functions running in the background simultaneously. Each function holds a bunch of old data. My computer started heating up, the fan got loud, and boom—Blue Screen.
3. The Process: Using the Memory Lab "microscope"
To debug this error, I couldn't use console.log. I had to use the Memory tool in Chrome DevTools.
- I took a Heap Snapshot when the app just opened.
- Performed the shape selection 50 times.
- Took a second snapshot.
- Used the Comparison feature to see what was created and not deleted.
The result was a long list of Detached HTMLElements. Those are the fingerprints of a Memory Leak. I realized hundreds of event handlers were still surviving even though the components had died long ago.
The final solution: Always, always have a cleanup function.
useEffect(() => {
const handleMouseMove = (e) => { ... };
window.addEventListener('mousemove', handleMouseMove);
return () => {
// Clean up before leaving
window.removeEventListener('mousemove', handleMouseMove);
};
}, [selectedId]);
4. Trade-offs: More code or more Trash?
Many will say: "Writing that removeEventListener is so tiring, why not use { once: true }?". It's true that { once: true } is very convenient, but it's only for events that run once (like clicking to open a menu). For continuous events like scroll, mousemove, or resize, manual cleanup is the only way.
The price to pay is that the code looks a bit longer and you have to always be alert. If you forget, you don't just break your app, but you also ruin the user's computer.
5. Junior vs. Senior: Resource Awareness
A Junior developer often only cares about the business logic: "How to get the shape to move with the mouse?". Once it runs, you consider the task complete.
A Senior developer spends 30% of their time thinking "How to clean up?". They understand that Frontend is a long-running process. A user might keep an app tab open all day. If every action they take leaks even just 1KB, then after 8 hours of work, that number becomes huge.
Sustainable solutions:
- Build Custom Hooks to manage events (e.g.,
useEventListener). Encapsulating cleanup logic in one place ensures you never forget. - Use the Chrome Performance Monitor to track RAM charts periodically.
- In large projects, try using WeakMap or WeakSet to store data attached to the DOM, helping trash get automatically cleaned when the DOM is deleted.
6. Lessons Learned
Source code is like a party. You can set out many delicious dishes (features), but if the party is over and you don't clean up, the house will become smelly and unlivable.
- Every
addEventListenermust have a correspondingremoveEventListener. - Every
setIntervalorsetTimeoutmust beclearedwhen unmounting. - Every WebSocket connection or Subscription must be closed.
Final advice: Become a "clean" developer. Don't let users have to restart their browser just because of one sloppy line of code.
Professional Frontend is when you leave a component and leave no trace in memory.
Series • Part 8 of 50