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?
answer
- no reload means no amnesty
- reachability decides, not scope
- who still points at the view
- window listeners, timers, observers, sockets
- one AbortController releases them together
basics
~20 sRelease 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 sGarbage 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 linesfunction 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
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.
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.
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.
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