Memory Leaks
The app is fine on load and slow an hour later. Something is being retained on every interaction and released on none.
The intent, the obvious build, and why it breaks
Every lesson starts where the work starts: someone wanted an outcome, and the first implementation that comes to mind has a problem.
Why does it get slower the longer someone leaves it open?
Someone keeps the application open all day and expects it to behave the same at five o'clock as it did at nine.
JavaScript is garbage-collected, so memory management is not a frontend concern. If it is slow, the code is slow.
Garbage collection frees what is unreachable. A listener you never removed is reachable, so it is not garbage — it is a live reference doing nothing (Detached Nodes and What Keeps Them Alive).
- Garbage collection frees what is unreachable. A listener you never removed is reachable, so it is not garbage — it is a live reference doing nothing (Detached Nodes and What Keeps Them Alive).
- The symptom is not "out of memory". It is gradual degradation: interactions get slower, collection pauses get longer and more frequent, and eventually a tab is discarded and the user loses their work (The Multi-Process Browser).
- It never reproduces on a fresh page load, which is how every test and every local check is run.
- In a single-page application the document never unloads, so anything retained accumulates across every route change for the entire session (Client-Side Routing).
- "It gets slow after a while" is not actionable as a bug report, so it is often filed as a vague complaint and closed.
What is actually happening
In the browser, not in the framework.
- An object is collected when nothing reachable from a root refers to it. Roots are globals, the stack, and — crucially for the browser — the DOM tree and anything the browser holds on your behalf.
- The classic frontend leak is a listener holding a closure holding a component subtree. The listener is attached to something long-lived (
window,document, a store), so it keeps its closure alive, and the closure keeps everything it captured alive. - Detached DOM is the signature case: nodes removed from the document that JavaScript still references. The browser cannot free them, and a detached subtree can be large (Detached Nodes and What Keeps Them Alive).
- Timers and intervals are roots. A
setIntervalthat is never cleared keeps its callback — and its captures — alive indefinitely. - An unbounded cache is not a leak in the strict sense, and behaves like one: it grows without limit because nothing evicts. The distinction matters for diagnosis and not for the user.
- Retained memory costs more than space. A larger heap means longer and more frequent collection pauses, and those pauses land on the main thread (Long Tasks).
What this makes the browser do
And which of it is avoidable.
- Garbage collection, whose cost scales with the size of the live set — so a leak makes every future collection more expensive.
- Style and layout over retained-but-invisible nodes if they are still attached somewhere off-screen (Style Invalidation).
- Under memory pressure, discarding the tab entirely and reconstructing it on return, losing in-memory state (Persistent Client State).
What actually holds on
Nearly every real frontend leak is one of a short list, and they share a shape: something long-lived acquired a reference to something short-lived, and nobody wrote the line that gives it back.
Seeing the shape matters more than memorising the list, because the fix is always the same — pair acquisition with release, in the same place.
| Trigger | Symptom | Cause | Response |
|---|---|---|---|
| Component mounts | Listener count on window grows with every navigation | addEventListener with no matching removal | Pass an AbortController signal and abort on teardown. |
| Observer created | Heap grows per route change; detached nodes accumulate | IntersectionObserver / ResizeObserver never disconnected | disconnect() in the same teardown as creation. |
| Polling started | Requests continue for a view nobody is on | setInterval not cleared | Clear on teardown; poll on visibility rather than on a timer (The Multi-Process Browser). |
| Store subscription | Handlers accumulate on a long-lived service | The returned unsubscribe was discarded | Keep it and call it; treat a returned function as an obligation. |
| Cache populated | Memory rises steadily and never plateaus | No eviction policy and an unbounded key space | Bound by size and age (Query Keys and Invalidation). |
| Element cached in a module | A whole subtree from a previous route stays alive | A module-level variable outlives the DOM it points at | Null it on teardown, or do not hoist DOM references out of scope. |
One handle for everything
The practical technique that removes most of this class is to make teardown a single operation rather than a checklist. AbortController works for far more than fetch: addEventListener accepts a signal, and one abort() detaches every listener registered with it.
The value is not brevity. It is that a checklist can be incomplete and a single handle cannot — you either called it or you did not.
function mount() {
window.addEventListener('resize', onResize)
document.addEventListener('keydown', onKey)
const t = setInterval(poll, 5000)
const obs = new ResizeObserver(onBox)
obs.observe(el)
const unsub = store.subscribe(onStore)
return () => {
window.removeEventListener('resize', onResize)
clearInterval(t)
// keydown, the observer and unsub were forgotten —
// and nothing anywhere will ever tell you
}
}function mount() {
const ac = new AbortController()
const { signal } = ac
window.addEventListener('resize', onResize, { signal })
document.addEventListener('keydown', onKey, { signal })
const t = setInterval(poll, 5000)
const obs = new ResizeObserver(onBox)
obs.observe(el)
const unsub = store.subscribe(onStore)
signal.addEventListener('abort', () => {
clearInterval(t); obs.disconnect(); unsub()
})
return () => ac.abort() // one call, everything released
}The first version leaks whenever someone adds a listener and forgets the matching line — which is a certainty over the life of a component. The second makes teardown a single operation, so the failure mode becomes "abort was never called", which is one visible thing rather than an invisible omission among many.
Leak or working cache
Not all growth is a defect, and treating a warming cache as a leak wastes time. The distinguishing evidence is the shape over time and the retainer chain — a cache is held by something you meant to hold it, and it stops growing.
- Three snapshots, not two: allocate a baseline, interact repeatedly, then compare — the middle step is what separates steady state from growth (Debugging Memory).
- Repeat the interaction ten times rather than once. One retained copy is noise; ten is a pattern.
- Start from detached DOM nodes when there are any — the retainer chain usually names the cause directly (Detached Nodes and What Keeps Them Alive).
| Signal | Leak | Cache doing its job |
|---|---|---|
| Shape over a session | Rises indefinitely, step per interaction | Rises then plateaus |
| After forced collection | Does not return toward baseline | Drops, or holds at a bounded ceiling |
| Retainer chain | Something you did not intend — a listener, a stale closure | The cache you deliberately wrote |
| Bound | None | A size or age policy you can point at |
| Repeating one route ten times | Ten copies retained | One copy, reused |
| Fix | Release the reference | Usually nothing — or lower the ceiling |
How to build it
Most important first.
- Pair every subscription with its teardown, in the same place, at the moment you create it. The distance between the two is where leaks live.
- Use
AbortControlleras one teardown handle for many listeners: pass the signal to everyaddEventListener, and a singleabort()removes all of them. - Bound every cache. A cache with no eviction policy is a leak with a justification (The Client Cache Model).
- Clear timers and intervals on teardown, and prefer a self-rescheduling timeout over an interval where the work can outlast its period.
- Do not hold DOM references longer than the nodes live. A module-level variable pointing at an element outlives every route that element belonged to.
- Be careful what a long-lived closure captures — capturing one field is cheap, capturing the object that owns the subtree is not.
- Test the way users behave: navigate the same route ten times and compare the heap, rather than measuring one load.
Keyboard, focus, semantics, announcement
A required field on every lesson in this domain, not a section added when there is room.
- Longer and more frequent collection pauses block the main thread, which stops accessibility-tree updates as surely as it stops painting — a screen reader reads stale content with no indication it is stale (The Multi-Process Browser).
- Degradation over a session hurts most the users who work slowly and deliberately, including many people using assistive technology, because they are the ones still in the same tab hours later.
- A tab discarded under memory pressure loses focus position and in-memory state entirely, which is a far larger setback for someone navigating by keyboard or screen reader than for someone who can re-find their place visually (Focus Management).
- Detached-but-referenced nodes are gone from the accessibility tree; the risk is the mirror case — nodes visually hidden but still attached and still announced (Live Regions and Announcement).
What can go wrong
- A global listener added per component instance and never removed, so every mount adds another.
- An observer (
IntersectionObserver,ResizeObserver,MutationObserver) created and never disconnected. - A store subscription whose unsubscribe is returned and discarded.
- A
setIntervalpolling an endpoint for a view the user left twenty minutes ago (Resynchronisation After a Gap). - An event emitter on a long-lived service accumulating handlers from short-lived components.
- A cache keyed by something unbounded — a query string, a user id, a timestamp — with no ceiling.
- A closure in a long-lived callback capturing a large response body that is otherwise finished with.
- Retained memory holds whatever was in it: tokens, personal data, message contents, form values. Data that should have been short-lived becomes long-lived by accident (Storage Security and Durability).
- A tab discarded and restored may repopulate from persisted state, so anything cached in memory for convenience should be considered for deliberate clearing on sign-out (Auth Across Tabs).
- A leak that grows with user input is a denial-of-service vector against the user's own device on a long-lived page.
- "Garbage collection means no leaks." It means no *unreachable* leaks. Every frontend leak is something still reachable that should not be.
- "Memory growth is a leak." A warming cache grows and then plateaus. A leak does not plateau — the shape over time is the tell (Debugging Memory).
- "It only matters for huge apps." It matters for any app someone leaves open, which is most internal tools and dashboards.
- "The framework cleans up." It cleans up what it created. Listeners on
window, timers, observers and subscriptions to external stores are yours. - "We would see it in error tracking." A leak produces slowness and tab discards, not exceptions.
Measuring it, and what changes in the field
- The three-snapshot method: take a heap snapshot, perform the interaction several times, take another, and compare what was allocated between them and never freed (Debugging Memory).
- Detached DOM nodes in a heap snapshot — usually the fastest route from symptom to cause, because the retainer chain names the thing holding them.
- A memory timeline across repeated navigations: a healthy application returns to roughly its baseline after collection, a leaking one ratchets upward.
- Listener counts on
windowanddocumentafter repeated route changes, which is a one-line check that finds a large share of real leaks. - Field signals are thin here, which is why this is one of the few areas where local profiling is the primary evidence (Measure Before Optimising).
- On a long session the effect compounds; on a short one it is invisible, which is why it survives testing.
- On a low-memory device, the tab is discarded much sooner, so the same leak produces data loss rather than sluggishness.
- In a single-page application it accumulates across routes; in a multi-page application each navigation frees everything, which is one of the MPA's underrated advantages (MPA vs SPA).
- Rigorous teardown is more code and is the only thing that works. Framework lifecycle hooks make it cheap; the cost is remembering.
- Bounding a cache costs hit rate and buys a ceiling — almost always the right trade in a long-lived client.
- Weak references let you hold something without retaining it, and make lifetime harder to reason about; useful in narrow cases, not a default.
Where this applies
Frontend advice ages badly and fragments across engines. These labels say what each claim is specific to, and where a different browser, device or framework would differ.
- GENERALReachability-based collection and the listener-holds-closure-holds-subtree pattern are properties of the language and the DOM, so they hold across every framework and browser.
- BROWSER-SPECIFICHeap snapshot tooling, retainer-chain presentation and the point at which a tab is discarded differ substantially between Chromium, Firefox and Safari, so the diagnosis workflow is browser-specific even though the leak is not.
- FRAMEWORK-SPECIFICAutomatic teardown coverage varies: effect-cleanup conventions handle subscriptions created inside a component, while anything attached to
window,documentor a module-level singleton is outside every framework's lifecycle and remains the author's responsibility.
Where the depth lives
This domain teaches the browser-side mechanism and hands the rest off.
- — Programming Languages & Runtime Internals — generational collection, why a larger live set makes every future collection more expensive, and what a collection pause actually does to a running program.
- — Testing & Reliability Engineering — a test that navigates a route repeatedly and asserts the heap returns to baseline is the only automated check that catches this class before users do.