Find the Memory Leaks Making Your SPA Slower by the Hour
Detached nodes, orphaned listeners, and growing caches bloat long sessions; heap snapshots expose and fix them.
A user opens your single-page app in the morning and leaves the tab open all day, navigating between views, opening and closing modals, letting dashboards refresh. By mid-afternoon the app feels sluggish. Scrolling stutters, interactions lag, and eventually the tab crashes or the OS starts swapping. They refresh, and it is snappy again, for a while. That pattern, fast on load and slower the longer it runs, is the fingerprint of a memory leak, and SPAs are uniquely prone to it because the page never reloads to clean up after itself.
In a traditional multi-page site, every navigation is a fresh start. The browser tears down the page and rebuilds it, and any sloppy memory usage gets wiped away. An SPA never gets that reset. The same JavaScript heap lives for the entire session, so anything you allocate and forget to release accumulates, hour after hour, until the session that started instant is crawling. The good news is that the leaks come from a small set of recurring causes, and Chrome DevTools can point you straight at them.
The three leaks that cause most of the damage
Almost every SPA leak is one of three things, and they often travel together.
Detached DOM nodes
This is the classic. A detached DOM node is an element you removed from the page, but a reference to it is still held somewhere in your JavaScript, in a global variable, a closure, or an event handler. Because something still points at it, the garbage collector cannot reclaim it. The element is gone from the screen but alive in memory, and worse, it can keep its entire subtree alive with it. Open and close a complex modal a few hundred times without releasing the references, and you have hundreds of invisible DOM trees squatting in the heap.
Orphaned event listeners and timers
This one is subtle because the code looks reasonable. A component mounts, attaches a resize listener to window, and starts a setInterval to refresh some data. When the component unmounts, you remove its DOM elements, and you assume that is the end of it. But you forgot to call removeEventListener and clearInterval. The interval keeps firing forever, the listener keeps responding to every resize, and both keep a reference to the component's closure, which keeps the component and everything it captured alive. Every time the component mounts and unmounts, you add another zombie interval and another dangling listener. The app does more and more work per event as the count climbs.
Caches and collections that only grow
A Map you use as a cache, an array you push results into, a store that accumulates fetched records and never evicts anything. Each is fine at small scale and becomes a leak at large scale, because nothing ever removes entries. The longer the session, the bigger the structure, the more memory it pins. This is the same scalability lens that applies to any data structure: the question is never how it behaves at ten entries, it is how it behaves at ten thousand, and a collection with no eviction policy answers that question badly, the same way an unbounded WebSocket buffer drowns in backpressure under load or a stray DB pool exhausts MySQL connections in dev reloads.
Finding them with heap snapshots
You do not have to guess which leak you have. Chrome DevTools' Memory panel makes the invisible visible, and the workflow is a comparison.
Take a heap snapshot. Then perform the interaction you suspect leaks, opening and closing the modal, navigating away and back, several times. Take a second snapshot. Now compare the two and look at what survived that should not have. The objects that persisted across the interaction, when they should have been released, are your leak.
To hunt detached DOM specifically, take a snapshot and type "Detached" into the class filter. DevTools lists the detached DOM trees still held in memory. Expand one and DevTools shows you the retaining path, the chain of references keeping it alive, often a closure or a variable you forgot about. The red nodes in the tree are detached elements with no direct JavaScript reference, kept alive only because some other node in the tree is still referenced. Follow the retainer back to the line of code holding the reference, and you have found the cause, not just the symptom.
The signal that confirms you have a real leak, rather than normal usage, is the trend. Repeat the interaction ten times, watch the heap size after each, and if it climbs monotonically and never comes back down even after forcing garbage collection, memory is leaking. A heap that grows and then settles is fine. A heap that only grows is the bug.
Fixing them, and preventing the next one
The fixes are unglamorous and reliable.
For detached DOM, find and drop the lingering reference. Null out the variable, remove the element from the cache, or restructure so the closure does not capture the node. Once nothing references the detached tree, the collector reclaims it.
For listeners and timers, the rule is that every subscription has an unsubscription, in the same place. If a component adds a listener or starts an interval on mount, it removes the listener and clears the interval on unmount, no exceptions. In component frameworks this is what the cleanup function of an effect is for. Treat a setup without a matching teardown as a bug, the same way you treat a long task that never yields the main thread as a bug rather than a quirk.
useEffect(() => {
const id = setInterval(refresh, 30000);
const onResize = () => reposition();
window.addEventListener("resize", onResize);
return () => {
clearInterval(id);
window.removeEventListener("resize", onResize);
};
}, []);
For growing collections, give every cache a bound. A maximum size with least-recently-used eviction, a time-based expiry, or simply clearing the structure when the relevant view unmounts. A cache that cannot grow without limit cannot leak without limit.
Why this is a reliability problem, not just a performance one
It is tempting to file memory leaks under "polish," something to look at if there is time. That undersells the cost. A leaking SPA degrades silently for exactly the users who engage most, the ones who keep the tab open all day, who use the product hardest. They are the users you most want to keep, and they are the ones who watch your app get slower and eventually crash. The leak does not show up in a quick QA pass, because QA opens the app, clicks a few things, and closes it. It shows up in hour six of real use, in front of your best customer, which is exactly why field data tells the truth a lab score hides.
That is why we treat long-session stability as part of building a web app properly, not a separate hardening phase. It is the same instinct as cutting your INP below 200ms before it tanks rankings: an interface that stays responsive under real, sustained use, not just on a fresh load. The discipline that prevents leaks, pairing every listener with a teardown, bounding every cache, never holding a reference to a node you removed, is the same discipline that keeps any long-running system healthy, and it is the kind of correctness we build into the web applications we ship. Catch the leak with a heap snapshot before your users catch it the hard way, and the app that was fast on load stays fast at hour eight.






