Memory Leaks in Long Lived Web Apps
A memory leak in a web application occurs when memory that is no longer needed is not released by the JavaScript runtime's garbage collector. In long-lived single-page applications where users navigate between views without a full page reload, leaked memory accumulates over time, eventually causing the browser tab to slow down, become unresponsive, or crash. The most common sources are event listeners that are added without being removed, subscriptions that are not cleaned up on component unmount, and closures that hold references to large objects.
Written by Yashveer Singh, founder of Yashveer Labs.
What you need to know
- Memory leaks in SPAs accumulate over time. A five-minute session may not reveal a leak that becomes obvious after 30 minutes of use.
- The most common leak pattern in React is an event listener or subscription added in useEffect without a cleanup function.
- Detached DOM nodes are the diagnostic signal most commonly associated with leaks. They indicate elements that were removed from the DOM but are still referenced in JavaScript memory.
- Chrome DevTools Memory panel with heap snapshots is the correct tool for diagnosing browser memory leaks. JavaScript profilers and performance monitors do not provide the object-level visibility needed.
- Fixing leaks requires understanding why the reference is being held, not just that memory is growing.
The core argument
The memory leak problem in long-lived web applications is a diagnosis problem more than a prevention problem. Most engineers can write correct cleanup logic once they know where the leak is. The difficulty is in detecting that a leak exists at all, identifying which component or interaction causes it, and confirming that the fix actually eliminates the retained memory.
The most common pattern in React applications is the missing useEffect cleanup. An effect that subscribes to a WebSocket connection, registers a window resize listener, or sets up a polling interval creates a resource that must be released when the component unmounts. Without the cleanup return function, each mount of the component adds another subscription or listener. If the component mounts and unmounts repeatedly, say, a modal or a side panel that the user opens and closes, the leaked subscriptions accumulate in memory. The fix is identical every time: return a function from useEffect that removes the listener, cancels the subscription, or clears the interval.
Third-party library leaks are harder to find because they require reading the library's lifecycle documentation rather than just the application code. A chart library that creates a Canvas element and attaches browser event listeners needs a destroy method called when the component unmounts. An analytics library that sets up a session with reference-counted state needs an explicit teardown. I have found leaks in production applications at Expert Tutorials and other client projects that were traced to a chart library whose cleanup API was called in the wrong lifecycle phase, resulting in event listeners that survived component unmounts. Finding it required heap snapshot comparison, not code review.
Common mistakes
- Not providing cleanup functions in useEffect hooks that create subscriptions. Every useEffect that calls
addEventListener,setInterval,subscribe, or equivalent must return a cleanup function that undoes that operation. This is not optional for long-lived applications.
- Storing component-scoped values in module-level variables. A module-level Map or Set that grows as components mount and does not shrink as they unmount is a leak. Module-level storage should hold only data with an appropriate lifecycle, not component-instance data.
- Not testing memory behavior in long-running sessions. Short test sessions do not reveal accumulating leaks. Test memory behavior by running the application for 20 to 30 minutes with repeated navigation between the most common routes, opening and closing modals, and triggering data refreshes.
- Holding references to large objects in closures unnecessarily. A callback that closes over an entire component's props object when it only needs one field holds more memory than necessary. Capture the minimum required values in closures, especially in long-lived callbacks.
- Not checking for detached DOM nodes after fixing a suspected leak. After applying a fix, re-run the heap snapshot comparison and verify that detached DOM node count is no longer growing. A fix that reduces growth rate without eliminating it has addressed a symptom, not the source.
Where to start
- Profile the application's memory usage during a 15-minute session. Use Chrome DevTools Performance tab with memory recording enabled. If the heap size grows monotonically without periods of garbage collection bringing it back down, you have a leak worth investigating.
- Take three heap snapshots: at initial load, after 10 minutes of use, and after 20 minutes. Compare the second and third snapshots for objects that continue to grow. The Constructor column shows which object types are accumulating.
- Audit every useEffect in the codebase for missing cleanup functions. Search for addEventListener, subscribe, setInterval, and setTimeout calls inside useEffect hooks. For each one, verify that a cleanup function returns and undoes the operation.
Related reading
- INP: The New Core Web Vital Most Teams Are Failing
- Frontend Performance Budgets: A Pattern That Sticks
- React Server Components vs Client Components: The Real Trade-offs
- Lazy Loading: The Patterns That Work and the Ones That Backfire
Frequently asked
Why I am built for this project type
I have worked on five production systems before turning eighteen. That is not a flex. That is a statement of capability. Yashveer Singh, founder of Yashveer Labs. The work in this article is the work I do on a weekly basis. If you are facing the problem I just described, I do not need to be sold on solving it. I need to be told the constraints.
Posts that line up with this one.
- Performance Optimization
Image Optimization at Scale: AVIF, WebP, Responsive Images
Images are the largest contributor to page weight on most web products. Here is the format selection, responsive image, and delivery strategy that cuts load time without manual work.
- Performance Optimization
INP: The New Core Web Vital Most Teams Are Failing
Interaction to Next Paint replaced First Input Delay in 2024 and it is harder to pass. Most teams have not caught up. Here is what INP measures, why it matters, and how to fix the common failure patterns.
- Performance Optimization
Largest Contentful Paint: The Metric That Changes Conversions
LCP is the Core Web Vital that measures how fast the main content loads. It is also the metric most directly correlated with conversion rate. Here is what causes poor LCP and how to fix it systematically.
- Performance Optimization
Lazy Loading: The Patterns That Work and the Ones That Backfire
Lazy loading reduces initial page weight when done correctly. When done incorrectly, it delays the content users actually need and hurts Core Web Vitals. Here is how to apply it with precision.