Scenario Debugging
Memory Leaks in Single-Page Apps
Timers and listeners that are never cleared, detached DOM nodes, closures that hold big objects and caches that only grow. Also how to confirm a leak with heap snapshots.
On this page
In one line: a memory leak is memory your app no longer needs but still points to, so the garbage collector cannot free it. In a single-page app (SPA) the tab never reloads. So small leaks add up on every navigation, until the page becomes slow or crashes.
Key terms used in this lesson:
- Memory leak is a bug where a program keeps memory it no longer needs, so its memory use only goes up. Example: a list that gets new items but never loses old ones.
- Garbage collector (GC) is the part of the JavaScript engine (such as V8 in Chrome and Node) that finds memory nobody can use any more and frees it for you. Everyday comparison: a cleaner who throws away only the things nobody owns.
- Heap is the area of memory where JavaScript objects, arrays and functions live.
- Reference is a link from one value to another. If
a.child = b, thenaholds a reference tob. - SPA (single-page application) is a web app that loads once and then changes the screen with JavaScript, without reloading the page.
On a server, a leak shows up as a process that keeps growing. In a browser, you see a tab that gets slow after an hour of use, a laptop fan that never stops, or "Aw, Snap!" on a phone. SPAs are especially exposed, because route changes do not reload the page. Anything you forget to clean up stays alive.
How garbage collection decides what to free
JavaScript frees an object when it is unreachable from the roots. A root is a starting point that the engine always treats as alive. Roots include the global object (window), the variables on the current call stack, active timers and listeners, and the DOM (the tree of page elements). An object is reachable if you can get to it from a root by following references. If something that is still reachable points to an object, that object stays. This is true even if you think the object is dead.
Everyday comparison: think of a coat rack. A coat stays on the rack while someone holds its ticket. Nobody has to wear the coat. The ticket is enough to keep it there.
roots ─▶ window ─▶ someCache (Map) ─▶ bigObject ← reachable, so never freed └▶ setInterval callback ─▶ closure ─▶ detached <div> tree ← also kept aliveA retention path is the chain of references from a root to an object. It is the reason that object is still alive. So a leak is always a retention path to something that should have died. To debug a leak, you find that path.
A closure is a function together with the variables it remembers from the place where it was created. A closure keeps those variables alive for as long as the closure itself is alive. Example: const f = () => big.length; remembers big.
The common sources in SPAs
1. Event listeners that are never removed
An event listener is a function that the browser calls when an event happens (a click, a resize, a message). addEventListener registers it. The target (such as window) holds a reference to the function until you remove it. An observer (ResizeObserver, IntersectionObserver, MutationObserver) is a similar object that calls your function when the size, visibility or content of an element changes.
// ❌ A new listener per mount; the old ones (and everything they close over) stay foreveruseEffect(() => { window.addEventListener("resize", () => setWidth(window.innerWidth));}, []);// ✅ Remove it on cleanup, with the same function referenceuseEffect(() => { const onResize = () => setWidth(window.innerWidth); window.addEventListener("resize", onResize); return () => window.removeEventListener("resize", onResize);}, []);The same is true for listeners on document, window, matchMedia (media-query changes) and BroadcastChannel (messages between tabs). It is also true for the observers above (call disconnect()) and for any custom event emitter (an object with its own on and off methods).
2. Timers and intervals
useEffect(() => { const id = setInterval(refreshPrices, 5000); return () => clearInterval(id); // without this, the interval (and its closure) lives on after unmount}, []);setInterval runs a function again and again at a fixed delay. clearInterval stops it. The timer system holds the function (and its closure) until you stop it. An interval that keeps calling setState on an unmounted component also keeps the component and its closures alive.
3. Subscriptions and connections
A subscription is a long-lived link to a source that pushes data to you. Examples are WebSockets (a two-way connection to a server), SSE (EventSource, a one-way stream from a server), RxJS subscriptions, store subscriptions, Firebase/GraphQL subscriptions, and postMessage listeners. The source holds your callback until you unsubscribe. Always return an unsubscribe or close function in the effect cleanup.
4. Detached DOM nodes
A detached DOM node is an element that was removed from the page but is still referenced from JavaScript. The reference can be a variable, a cache, a closure, or an array of "rows". It keeps the element and all its children (the whole subtree) in memory.
let cachedRow; // module-level referencefunction remember(el) { cachedRow = el; }// el is later removed from the document, but cachedRow still points to it → "Detached HTMLDivElement"5. Closures capturing large objects
A callback is created in a scope that holds a big array or response. The callback keeps all of it alive as long as the callback lives. This is true even if the callback uses only one small field.
function attach(el, bigResponse) { const id = bigResponse.id; el.addEventListener("click", () => track(bigResponse.id)); // captures the entire bigResponse}// Better: capture only what you need// el.addEventListener("click", () => track(id));6. Caches and collections that only grow
const cache = new Map<string, Data>(); // module-level, never clearedexport async function getUser(id: string) { if (!cache.has(id)) cache.set(id, await fetchUser(id)); return cache.get(id)!; // grows with every distinct id for the life of the tab}Fixes:
- Bound the cache. Set a maximum size or a TTL. An LRU (least recently used) cache is a cache that removes the item used longest ago when it is full. A TTL (time to live) is a time limit after which an entry is deleted.
- Use
WeakMap/WeakRefwhen the keys are objects that should be allowed to be collected. AWeakMapis a Map whose keys are objects held weakly. A weak reference does not keep the object alive. When nothing else points to the key object, GC may remove the entry. AWeakRefis an object that holds one weak reference to another object. - Use a data-fetching library that removes unused queries for you (React Query's
gcTime, which is how long an unused query stays in its cache).
7. Third-party widgets that need explicit teardown
Charts, maps, rich-text editors, video players and virtualisation libraries create their own objects, canvases and listeners outside React. They can all hold on to memory. If they have a destroy(), dispose() or remove() method, call it in the cleanup.
useEffect(() => { const chart = new Chart(canvasRef.current!, config); return () => chart.destroy(); // otherwise each mount leaves a chart (and its listeners) behind}, []);8. In-flight requests and unresolved promises
A request that stays pending for a long time may have a callback that uses component state. That keeps the component alive. Abort requests when the component unmounts. AbortController is a built-in object that cancels work such as a fetch: you pass its signal to the request and call abort() later. This also fixes updates with stale data. See Lesson 05.
9. Growing app state
These things grow without limit:
- Redux/Zustand state (global stores for app data) that keeps collecting data (a log that never stops growing, normalised entities that are never removed).
- Undo history with no cap.
console.logof large objects. DevTools keeps references to logged objects while it is open. This can make a leak look worse in development.
10. Global variables and accidental globals
An accidental global is created when you assign to a variable you never declared (total = 5 with no let or const). In sloppy mode this makes a property on window. Globals are roots, so they are never freed. Another source is attaching data to window on purpose and forgetting about it. Modules and strict mode ("use strict", which is on by default in ES modules) turn the accidental case into an error.
Why effect cleanup matters (and what Strict Mode teaches)
Strict Mode is a React development tool (<StrictMode>) that runs extra checks. One check mounts a component, unmounts it, and mounts it again. This checks that your effect cleanup really undoes the setup. A missing cleanup shows up at once. You see duplicate listeners, doubled intervals or repeated subscriptions. Treat this as a free leak detector, not as an annoyance.
How to diagnose a leak in Chrome DevTools
Step 1: notice the symptom with the Performance monitor
The Performance monitor is a live chart in Chrome DevTools. Open DevTools → More tools → Performance monitor. Watch JS heap size, DOM nodes and JS event listeners while you use the app.
- Healthy memory looks like a sawtooth. It goes up, then drops after garbage collection, and returns to about the same base level.
- A leak looks like a staircase or an upward trend. Each cycle ends higher than the last one, and the node and listener counts keep rising.
Step 2: compare heap snapshots
A heap snapshot is a photo of every object in the heap at one moment. Go to the Memory tab and choose Heap snapshot:
- Load the page. Trigger garbage collection (the trash-can icon in the Memory tab). Take Snapshot 1 (the baseline). Use a fresh Incognito window with extensions off, so that extensions do not add noise.
- Do the suspicious action several times. For example, go to the route and back 10 times, or open and close a modal.
- Trigger GC again and take Snapshot 2.
- Select Snapshot 2 and choose "Objects allocated between Snapshot 1 and Snapshot 2" (or compare). Sort by Retained Size or by count. Shallow size is the memory of the object itself. Retained size is the memory that would be freed if this object were deleted (the object plus everything only it keeps alive).
- Look for objects whose count grows in step with the number of repeats. For example, 10 actions give 10 extra component instances and 10 listeners.
- Type "Detached" in the class filter to find detached DOM trees.
- Select a suspicious object and read the Retainers panel at the bottom. A retainer is an object that holds a reference to the selected object. The panel shows the chain of references from a GC root to the object. That chain is the leak. For example:
window → Map → closure → HTMLDivElement.
Step 3: allocation tracking
- Allocation instrumentation on timeline records allocations over time and highlights those that survive. It is good for finding the function that creates the leaked objects.
- Allocation sampling is a low-cost mode that takes samples of allocations. It shows which functions allocate the most memory.
Step 4: confirm the fix
After the fix, repeat the same action 10 times. Check that the snapshot difference no longer grows, and that the sawtooth goes back to its base level.
Scenario: the tab's memory grows by 50 MB on every route change. What next?
- Reproduce it on purpose. Go A → B → A many times while you watch the Performance monitor. Confirm the staircase.
- Narrow it down. Does it grow on every route, or only on one? Switch off features (a chart, a websocket) one by one to find the cause.
- Compare heap snapshots around 5 to 10 navigations. Sort by retained size and filter "Detached". Find the class (a page component, a big array, a canvas).
- Read the retainer chain to see what holds it. It may be a module-level cache, a listener on
window, a timer or a store subscription. - Fix that retainer (add cleanup, bound the cache, destroy the chart, unsubscribe).
- Run the same test again and compare snapshots. Add a guard. For example, a Playwright test (an automated browser test) can navigate many times and check that heap growth stays under a limit. Or you can watch memory in field data (data from real users).
performance.memoryis a Chrome-only, non-standard API that reports heap size.performance.measureUserAgentSpecificMemory()is a newer, more exact API, but it works only on pages that are cross-origin isolated.
A 50 MB step per route is usually a large retained structure (an API response, a set of decoded images, a chart's canvas buffers). It is not usually tiny listeners. The retainer chain tells you which one it is.
Memory problems on the server
The same ideas apply in Node. If RSS (resident set size: the amount of RAM the operating system has given to the process) or the heap keeps growing over days, the usual causes are:
- Caches without a limit.
- Listeners added for every request (
MaxListenersExceededWarning). - Global arrays or maps.
- Streams or connections that are never closed.
- Closures that keep large request objects.
Use heap snapshots (node --inspect with Chrome DevTools, or node --heapsnapshot-signal=SIGUSR2, which writes a snapshot file when the process gets that signal) and process.memoryUsage() metrics (it returns rss, heapUsed and more). Restarting the process helps for a short time while you look for the retainer. See Backend, Lesson 10.
Prevention habits
- Every
addEventListener,setInterval,subscribeandnew Observergets a matching cleanup, in the sameuseEffect. - Put a limit on every cache (size and/or TTL). Prefer libraries that manage the cache lifetime for you.
- Use
AbortControllerfor requests and for removing event listeners (the{ signal }option). - Prefer
WeakMap/WeakSet(a Set that holds its objects weakly) for extra data that is keyed by objects. - Destroy third-party instances when the component unmounts.
- Do not keep DOM nodes in module-level variables.
- Add a long-running memory test to CI for key flows. For heavy apps, also track memory and crash rates in the field.
// One AbortController signal removes many listeners at onceconst ctrl = new AbortController();window.addEventListener("resize", onResize, { signal: ctrl.signal });window.addEventListener("scroll", onScroll, { signal: ctrl.signal, passive: true });// cleanup:ctrl.abort(); // removes bothCommon mistakes
- Adding listeners in effects with no cleanup.
- Intervals and timeouts that are never cleared.
- Module-level caches and arrays with no limit.
- Leaving chart, map or editor instances alive after unmount.
- Holding references to DOM nodes that were removed.
- Diagnosing with a single snapshot instead of comparing before and after an action.
- Mistaking the normal GC sawtooth for a leak, or ignoring a slow staircase.
- Forgetting that open DevTools and
console.logof big objects keep memory alive.
Interview questions
Name common sources of memory leaks in React apps.
Common sources are:
- Event listeners and observers that are not removed.
- Timers and intervals that are not cleared.
- Subscriptions (WebSocket, store, RxJS) that are not unsubscribed.
- Third-party instances (charts, maps) that are not destroyed.
- Module-level caches without a limit, or state that keeps growing.
- Closures that keep large objects.
- Detached DOM nodes that JavaScript still references.
- Requests still in flight that keep unmounted components alive.
Effect cleanup functions are the main defence.
How do you use heap snapshots to confirm a leak?
I force GC and take a baseline snapshot. Then I repeat the suspected action many times, force GC, and take a second snapshot. I compare the two, or filter "objects allocated between snapshots". Objects whose count grows with the number of repeats are suspects. "Detached" DOM nodes are suspects too. The Retainers panel shows the chain of references from a GC root that keeps them alive. That chain is the thing to fix.
Scenario: the tab's memory grows by 50 MB on every route change. What next?
I repeat the navigation while I watch the Performance monitor. I find which route or feature is responsible by switching parts off. Then I compare heap snapshots, sort by retained size, and look at the retainer chain. This shows what holds the large structure: a cache, a listener, a timer or a subscription. I fix that reference and repeat the test to confirm there is no more growth. Then I add a regression check (an automated test that fails if the bug comes back).
Why does effect cleanup matter?
Effects set up outside resources (listeners, timers, subscriptions) that React does not manage. Without cleanup, they stay after unmount or run again. They repeat work and keep the component and its closures alive. In development, Strict Mode mounts twice on purpose, so it shows missing cleanups.
How does a closure cause a leak?
A function keeps its whole surrounding scope reachable. Suppose a long-lived function (an event handler, a timer callback, a cached promise) closes over a large object. Then that object cannot be freed, even if the function only uses a small part of it. Capture only the values you need. Release long-lived callbacks when you are done.
What is a detached DOM node?
It is an element that was removed from the document but is still referenced from JavaScript. So the element and its subtree stay in memory. Heap snapshots list them under "Detached" classes. Find the retainer (a variable, cache, closure or array) and remove the reference.
How do you tell a leak from normal garbage-collection behaviour?
Normal memory forms a sawtooth. It grows, then drops back to a stable base level after GC. A leak shows a base level that keeps rising (a staircase) when you repeat an action. The DOM node or listener counts keep growing as well. To confirm, force GC, repeat an action, and compare snapshots.
When would you use WeakMap or WeakRef?
I use them when I want to attach extra data to objects without stopping them from being collected. An example is caching computed data for each DOM node or each object. An entry disappears by itself when nothing else references the key object. Use them with care. They depend on when GC runs, so they are not right for logic that must always behave the same way.
Key takeaways
- A leak is a retention path from a long-lived root to something that should be dead.
- SPAs leak through listeners, timers, subscriptions, caches, detached DOM, closures and widgets that are not destroyed.
- Spot it with the Performance monitor (a staircase heap, rising listeners and nodes). Confirm it with snapshot comparisons and read the Retainers.
- Always write cleanup. Put a limit on caches. Use AbortController. Destroy third-party instances.
- Add a repeat-navigation memory test so leaks do not come back.
What's next
Lesson 05: Duplicate Requests, Race Conditions and Stale Responses. Not all slowness comes from slow code. Sometimes the app does the same work twice. Sometimes it shows the answer to a question the user has already changed.
Published by Jainik · Last updated · How these lessons are made
Keep learning
Related lessons
- How a Computer Runs Your Program · CS Foundations
- Memory, CPU Caches and Why Array Order Matters · CS Foundations
- React Rendering Model: Render, Commit, Reconciliation and Keys · Senior Frontend Depth
Related series