skip to content

Memory Leaks in SPAs

Single-page apps never reload, so small leaks accumulate into a sluggish or crashing tab. Interviewers ask for concrete leak sources and how you would prove one exists.

on this pageshow

questions

5

A single-page app swaps views in and out without ever reloading the document. When a view is torn down, what must its setup code release so the view's memory can be reclaimed, and why does skipping that matter more here than on a traditional multi-page site?

level: juniorimportance: must knowfreq 62%

answer

  1. no reload means no amnesty
  2. reachability decides, not scope
  3. who still points at the view
  4. window listeners, timers, observers, sockets
  5. one AbortController releases them together

basics

~20 s

Release everything setup registered on something longer-lived than the view: listeners on window or document, timers, observers, store and socket subscriptions, in-flight requests, and entries pushed into module-level collections. A multi-page site gets a clean heap on every navigation; an SPA never does.

solid answer

~50 s

Garbage collection is driven by reachability, so a view is only freed once nothing long-lived still points at it. Teardown therefore has to undo every edge setup created outward: `removeEventListener` for handlers registered on `window`, `document` or any shared element; `clearTimeout`/`clearInterval` for timers; `disconnect()` on `IntersectionObserver`, `ResizeObserver`, `MutationObserver` and `PerformanceObserver`; the unsubscribe function returned by a store; `close()` on a `WebSocket` or `EventSource`; aborting in-flight `fetch` calls; and removing anything the view pushed into a module-scope array or `Map`. The cheapest way to get this right is one `AbortController` per view — its `signal` is accepted by both `addEventListener` and `fetch`, so a single `abort()` releases them together. It matters more in an SPA because the document never reloads: the same leak repeats on every visit to that view and accumulates over a session that may last all day.

code

javascript · 18 lines
javascript
function mountView(container) {
  const ac = new AbortController();
  const { signal } = ac;

  window.addEventListener('resize', () => container.classList.toggle('narrow', innerWidth < 700), { signal });
  document.addEventListener('keydown', (e) => { if (e.key === 'Escape') container.hidden = true; }, { signal });

  const observer = new IntersectionObserver(() => {});
  observer.observe(container);
  signal.addEventListener('abort', () => observer.disconnect(), { once: true });

  fetch('/api/rows', { signal }).catch(() => {});

  return () => ac.abort();
}

const destroy = mountView(document.body);
destroy();

go deeper

for a junior

Be ready to list the concrete things a view registers — listeners on window or document, timers, observers, subscriptions, in-flight requests — and say plainly that an SPA never reloads, so nothing is cleaned up for you.

for a middle

Explain the mechanism, not the list: collection is by reachability from roots, so only edges pointing inward from long-lived objects matter. Show why a listener on a node inside the removed subtree is harmless while one on window is not.

for a senior

Demonstrate that you enforce this rather than remember it: acquire and release on the same line, one AbortController per view, and a cheap live-instance counter or a repeat-the-navigation heap check that proves teardown actually ran in a real session.

for a principal

Own the systemic angle: make the cleanup contract part of the view abstraction so a leak requires opting out, decide where lint rules or code review carry the burden, and set the bar for when a growing heap becomes a release blocker rather than a backlog ticket.

## Why a single-page app changes the stakes A traditional multi-page site gets an amnesty on every navigation. The browser destroys the document, throws away the JavaScript heap that belonged to it, and builds the next page from scratch. Sloppy cleanup is invisible because nothing survives the trip. A single-page app gets no such reset. One document is created when the user arrives and lives until the tab is closed — through hundreds of view changes and, on an internal tool or a dashboard, an entire working day. Everything the app fails to release stays in that one heap. A view that retains 200 KB is unnoticeable the first time and painful after the user has opened it forty times: the tab gets slower (larger heaps mean longer and more frequent garbage-collection pauses on the main thread) and eventually the browser may discard or crash it. ## The rule that decides what leaks JavaScript memory is reclaimed by *reachability*, never by scope or intent. The collector starts from a set of roots — the global object, the DOM tree currently attached to the document, and the values on the executing stack — and keeps anything it can walk to. "This view is finished" is not a fact the collector can use; only "nothing reachable points at it any more" is. So the only question at teardown is: **did setup create a reference edge from something long-lived to something view-sized?** If it did, that edge has to be cut. If it did not — a listener attached to an element inside the view's own subtree, a local variable, a DOM node the view created and dropped — the whole cluster becomes unreachable together and the collector handles it with no help from you. ## The checklist - **Listeners on long-lived targets.** `window`, `document`, `document.body`, a shared toolbar element, a third-party SDK object. `removeEventListener` needs the *same function reference* and a matching capture flag; an inline arrow function passed at registration time can never be removed later. - **Timers.** Anything started with `setInterval` or a pending `setTimeout` must be cleared, because the pending timer keeps its callback — and everything the callback can reach — alive. - **Observers.** `IntersectionObserver`, `ResizeObserver`, `MutationObserver`, `PerformanceObserver` all need `disconnect()`. An observer with live targets is retained by the browser, and it retains its callback. - **Subscriptions and connections.** The unsubscribe function a store or event emitter returns, `close()` on a `WebSocket` or `EventSource`, `port.close()` on a `MessageChannel`. - **In-flight work.** A `fetch` whose `.then` resolves into a destroyed view keeps that view alive until it settles, and then usually writes into a DOM that no longer exists. - **Module-scope collections.** Any array, `Map` or `Set` at module level that setup pushes into and teardown never trims is an append-only leak. - **Cached node references.** A module-level `let lastSelectedRow = row` pins the node's whole tree after removal. ## One idiom that covers most of it `AbortController` collapses several of these into a single call. Its `signal` is accepted by `addEventListener` (the listener is removed automatically when the signal aborts) and by `fetch` (the request is cancelled). You can also hang your own cleanup off `signal.addEventListener('abort', …)` for observers and sockets, so teardown is literally one `abort()`. ```js function mountView() { const ac = new AbortController(); const { signal } = ac; window.addEventListener('resize', () => layout(), { signal }); const io = new IntersectionObserver(onVisible); signal.addEventListener('abort', () => io.disconnect(), { once: true }); return () => ac.abort(); } ``` ## Where teardown lives Every UI approach has a place for it — a `destroy()` method, a custom element's `disconnectedCallback`, a cleanup function returned by the setup routine — and the framework-agnostic discipline is the same: **register the release on the same line you create the subscription**, so the two cannot drift apart in review. Code that acquires in one file and releases in another is where leaks live. ## Proving you got it right The cheap test is repetition: enter the view and leave it ten times, then look for ten copies of anything that should exist once. A heap snapshot after a forced collection will show them; so will a counter incremented in setup and decremented in teardown, which costs nothing and catches the common case of a teardown path that simply never runs.

  • A view attaches its click handler to a button inside its own subtree and never removes it. Is that a leak?
    Normally no. The handler is reachable from the button, the button from the view's subtree, and once that subtree is removed from the document and no JavaScript variable points into it, the node, the handler and everything the handler closes over become unreachable as one cluster and are collected together. It only leaks if something long-lived still holds a reference to a node in that subtree.
  • Why does removeEventListener sometimes silently fail to remove a handler?
    Because removal matches on the target, type, capture flag and the *identity* of the function. Passing a fresh arrow function or a `.bind(this)` result at removal time creates a different function object, so nothing matches and the original stays registered. Keep the handler in a variable, or register it with an `AbortController` signal so removal never depends on matching references.
  • How would you catch a teardown path that never runs at all?
    Instrument it. Increment a module-level counter in setup and decrement it in teardown, then log the value from the console after navigating in and out of the view several times; a number that climbs proves cleanup is not executing rather than executing incorrectly. It is a two-line probe and it distinguishes a missing teardown from an incomplete one before you open a profiler.

A multi-page site is a hotel room cleaned between guests; an SPA is your own flat. Nobody comes in overnight to throw out what you left behind, so every small mess is cumulative.

saying these in an interview costs you the question

  • Says garbage collection frees a view when it goes out of scope
  • Claims every event listener must be removed manually
  • Thinks removeEventListener works with a new inline arrow function
  • Believes setting a variable to null forces immediate collection
  • Assumes a router unmount automatically cancels timers and requests

context

open as a page

In a browser application, what is a "detached DOM tree", and how can one variable still pointing at a single removed node keep megabytes of nodes alive?

level: middleimportance: should knowfreq 48%

basics

~20 s

A detached DOM tree is a group of nodes removed from the document but still referenced from JavaScript, so the browser cannot free them. Because every node references its parent and every parent its children, holding one node keeps its entire former tree in memory.

open as a page

A browser SPA keeps `const rowMeta = new Map()` at module scope, keyed by the `<tr>` elements it renders, and adds an entry every time a table is drawn. Why does this grow without bound as the user navigates, and does switching it to a `WeakMap` fix it?

level: middleimportance: should knowfreq 40%

basics

~20 s

A Map holds its keys strongly, so every row element ever rendered stays reachable through the module-level Map and its whole table with it. A WeakMap holds keys weakly and does fix this case, but only for object keys and only if nothing else still references the element.

open as a page

Users report that a browser tab running your SPA becomes sluggish after half an hour of moving between views, and the tab's memory never comes back down. How would you establish that this is a genuine leak rather than normal caching, and identify what is retaining the memory?

level: seniorimportance: should knowfreq 45%

basics

~20 s

Reduce it to a repeatable round trip, then take heap snapshots after equal numbers of cycles and compare them. A leak grows by a similar amount every cycle and never plateaus; a cache rises and levels off. The retainers path of a growing object names the reference to cut.

open as a page

A dashboard your team ships runs unattended on wall displays for weeks, and the current mitigation for its steadily growing memory is a nightly location.reload(). As the lead, how do you decide whether that is an acceptable answer or whether the underlying leak has to be fixed?

level: principalimportance: nice to knowfreq 22%

basics

~20 s

Decide on evidence: measure the growth rate per hour against the device's headroom, and check who else runs this code. A scheduled reload is a legitimate control for a kiosk with slow, bounded growth; it is a cover-up when ordinary user sessions hit the same ceiling.

open as a page