Yashveer Singh
Connect
<- All posts
Performance Optimization6 min read

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

  1. 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.
  1. 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.
  1. 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.
  1. 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.
  1. 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

  1. 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.
  1. 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.
  1. 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

FAQ

Frequently asked

Author

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.

Related reading