What you'll learn: By the end of this lesson you will be able to explain why an object stays in memory (it is reachable from a root, not "still needed"), name the usual sources of leaks in a long-lived page (listeners, timers, detached DOM nodes, caches, subscriptions), find one by doing an action and its undo, comparing heap snapshots and reading the retainer chain, choose a fix (explicit cleanup, an abort signal, a weak-keyed map, a bounded cache), and read a measurement series without mistaking a one-off cost for a leak. The lab models the idea on an object graph; it does not model V8.
A coat-check desk gives you a ticket. The staff hang the coat on a rail and keep a note of the ticket, and when the ticket comes back the hook is free. Now suppose one ticket is pinned to the notice board by someone who forgot about it. The board is never taken down, so the coat can never be cleared, however long ago the guest left. The coat is not "in use". It is simply still pointed at from something that never goes away.
JavaScript memory behaves like that desk. The engine frees an object when nothing reachable still points at it. A memory leak in a page is a reference you meant to drop and did not, which connects a finished piece of the app to something that lives for the whole session.
The running example is a support console: agents open ticket panels in tabs and close them all shift long. Each panel is a DOM subtree with a chart, plus the data it draws. After a few hours the tab is sluggish. The JavaScript engine lesson explains how collectors work; this lesson is about what you do to keep them able to work.
The core idea
Three statements carry the lesson.
- Memory is freed when an object is unreachable, not when you are done with it. MDN reduces "no longer needed" to "unreachable": the collector starts from the roots (in JavaScript, the global object), follows references, and frees what it did not reach. Modern engines use mark-and-sweep, so two objects that point at each other are still freed once nothing reachable points at either.
- A leak is a chain from a root to something finished. Closing a panel removes it from the page, but if a
windowlistener, a timer or a module-level cache still refers to it, it stays. The element is then detached: out of the document but alive. Chrome's DevTools documentation defines it that way and calls detached nodes "a common cause of memory leaks". - You find a leak by measuring what survives an action and its undo, then reading what holds it. Guessing from code is slow; snapshots show the chain.
MDN adds a limit: you cannot trigger collection from JavaScript, and engines may expose that only behind flags. So never write code whose correctness depends on when memory is freed.
Figure 1. What keeps a closed panel alive
(a root)"] -->|"resize listener"| L["handler function"] L -->|"closure scope"| S["captured variables"] S -->|"panel"| P["Ticket panel
(detached)"] P --> C["chart canvas"] C --> D["ticket data"] DOC["document"] -. "removed child" .-> P
How to read this: the dotted line is the reference you removed when you closed the panel. The solid chain from the root is the one you forgot. As long as it exists the panel, the chart and the data are all reachable.
Where leaks come from
Every common leak is the same shape: something long-lived holds a callback or a reference to something short-lived.
| Source | Why it holds the object | The fix |
|---|---|---|
Listener on a long-lived target (window, document, a shared emitter) |
The target's listener list refers to your function, and the function's scope refers to the panel | Remove it when the panel closes, or add it with an AbortSignal |
| Repeating timer | setInterval keeps running until clearInterval, so the callback and what it captured must stay |
Clear it on close |
| Detached DOM node held in a variable, array or map | A JavaScript reference counts, even when the element is not in the document | Drop the reference when you remove the node |
Module-level Map, array or cache that only grows |
The collection is reachable for the whole session | Bound it, evict, or key it weakly |
| Subscription, observer or socket callback | The source (a store, a connection) holds your callback | Unsubscribe or disconnect in the cleanup path |
| Late async completion | A response arrives after close and writes into the old panel | Cancel the request, or check that the panel is still current |
Two details are worth knowing. First, MDN's page on addEventListener notes that an anonymous function in a loop is a new function each time, so you cannot remove it later without a saved reference; keep a named function or use the signal option. Second, WeakMap (below) helps only for the "keyed by an object" case, and does not replace cleanup.
Cleanup that scales: an abort signal
The signal option of addEventListener removes the listener when the AbortController that owns the signal is aborted. One controller per panel can own many listeners:
function openPanel(ticket) {
const panel = renderPanel(ticket);
const lifetime = new AbortController();
const { signal } = lifetime;
window.addEventListener("resize", () => panel.fit(), { signal });
document.addEventListener("keydown", (e) => panel.onKey(e), { signal });
const timer = setInterval(() => panel.refresh(), 5000);
signal.addEventListener("abort", () => clearInterval(timer));
return function closePanel() {
lifetime.abort(); // removes both listeners and clears the timer
panel.remove();
};
}
The panel's lifetime now has one owner and one exit. AbortController is Baseline widely available (MDN, since March 2019). The same signal also cancels a fetch, which matters for the late-completion row above.
Weak references: what they promise
MDN states the rules. A WeakMap holds its keys weakly: a key being in a WeakMap does not stop it being collected, and the value is then a candidate for collection too, if nothing else holds it. Keys must be objects or non-registered symbols, and a WeakMap cannot be enumerated, because that would make results depend on collection. A value that refers to its own key does not keep the key alive: the entry is an ephemeron, not a pair of references.
const layoutCache = new WeakMap(); // keyed by the panel element
function layoutOf(panel) {
let layout = layoutCache.get(panel);
if (!layout) {
layout = measure(panel);
layoutCache.set(panel, layout);
}
return layout;
}
When the panel becomes unreachable, the cached layout becomes collectable. MDN notes two limits: a WeakMap cache works only for object inputs, and the value stays as long as the key is alive. WeakRef and FinalizationRegistry are weaker still: MDN says to avoid them where possible because their semantics are "almost completely unguaranteed", and a finalizer may never run. Use weak keys for caches, and explicit cleanup for anything that must happen.
Finding a leak: do, undo, compare
Chrome's DevTools documentation describes a two-snapshot method: take a snapshot, perform an operation, undo it by repeating the opposite interaction a few times, take a second snapshot, and compare.
Figure 2. The measurement loop
(starts with a collection)"] B --> C["Repeat action and undo
several times"] C --> D["Take snapshot 2"] D --> E["Comparison view:
objects allocated between 1 and 2"] E --> F["Filter: objects retained
by detached nodes"] F --> G["Retainers pane:
read the chain to a root"] G --> H["Fix, then run the loop again"]
How to read this: the undo matters. If closing the panel is a true inverse of opening it, nothing from the cycle should remain, so whatever the comparison shows between snapshots is a suspect.
What the tools show, from the DevTools pages:
- Task Manager and Performance recordings show whether memory keeps growing. Enable the Memory checkbox, start and end with a forced collection, and compare: a JS heap that ends higher than it began after a forced collection can indicate a leak.
- Heap snapshots show only objects reachable from the global object, and "taking a snapshot always starts with garbage collection". The Summary view groups by constructor; typing
Detachedin the Class filter lists detached elements; the Comparison view shows what was allocated between two snapshots. - Shallow size is the memory held by the object itself. Retained size is the memory you would free by deleting it, because its dependents would go too. A small detached
divthat holds a chart and a dataset has a large retained size. - The Retainers pane lists what points at the selected object. Reading it from the object up to a
Windowor a registry is the whole diagnosis. Allocation sampling and the Detached elements profile help find where objects are created and which DOM nodes are retained.
Frequent short collections look different: memory repeatedly rises and falls. That is allocation churn, not a leak.
Build it: a leak detector on an object graph
Goal. Watch the difference between "removed from the page" and "unreachable" on a small graph, find what keeps a view alive with a retainer path and a retained size, and see a fix turn growth into a flat line.
Setup. The lab is examples/frontend/labs/leak-detector-model. It needs Node.js (captured on Node 26.12.1).
cd examples/frontend
pnpm install
cd labs/leak-detector-model
pnpm test # 38 tests
node src/demo.ts
Steps.
- Read section 1:
aandbpoint at each other and are freed, because nothing reachable reaches them.heldstays. - In section 2, compare the two lines. As written, the live size grows by 33 abstract units per cycle and three detached DOM nodes pile up each time. With the fix, it is 9 every cycle.
- In section 3, read the retainer path for
view-0. Match it to Figure 1. The shallow size ofview-0is 2, but its retained size is 31, because the canvas, toolbar and data live only through it. - In section 4, the comparison between two snapshots lists exactly the six objects one open-and-close cycle left behind.
- In section 5, the timer and the strong
Mapproduce the same kind of growth; the fix isclearIntervaland a weak-keyed entry. Section 7 shows how the series is classified.
Expected output, captured from a real run (Node 26.12.1, 2026-11-08):
=== 1. Reachability: a cycle is freed, a held object is not
graph: window -> held; a <-> b (a and b point at each other, nothing points at them)
reachable from the roots: held, window
collect: freed a, b (size 4); live size 4
=== 2. resize-listener: open and close one view, five times
as written : live size after each forced collection = 42, 75, 108, 141, 174
nodes = 11, 17, 23, 29, 35; detached DOM nodes = 3, 6, 9, 12, 15
verdict: growing
with the fix: live size after each forced collection = 9, 9, 9, 9, 9
nodes = 5, 5, 5, 5, 5; detached DOM nodes = 0, 0, 0, 0, 0
verdict: stable
=== 3. What keeps the closed view alive? (resize-listener, as written, after 2 cycles)
detached subtree view-0: shallow size 2, retained size 31
retainer path: window --resize listener--> onResize-0 --closure scope--> scope-0 --captures view--> view-0
detached subtree view-1: shallow size 2, retained size 31
retainer path: window --resize listener--> onResize-1 --closure scope--> scope-1 --captures view--> view-1
=== 4. Snapshot comparison: cycle 3 against cycle 4 (resize-listener, as written)
snapshot 3: 23 objects, size 108; snapshot 4: 29 objects, size 141
added between them (6): canvas-3, data-3, onResize-3, scope-3, toolbar-3, view-3
removed: none; size delta 33
=== 5. All three scenarios, eight cycles each
scenario as written (final size, verdict) with the fix (final size, verdict)
resize-listener 273, growing 9, stable
interval-timer 513, growing 9, stable
element-cache 290, growing 10, stable
=== 6. Weak-keyed entries: what they do and do not promise
entry (key view-0, value layout-0) with nothing else holding either: live = false
same entry, but app still references view-0: key live = true, value live = true
=== 7. Reading a series of measurements
100, 100, 100, 100 -> stable
100, 133, 166, 199 -> growing
100, 133, 120, 150 -> noisy
100, 133 -> too-few-cycles
This models the idea, not V8. The sizes are abstract units, the collector is a plain mark and sweep with no generations, and whether a real closure keeps a particular variable alive depends on the engine. The lab cannot say how much memory a real page uses, or how long a real collection takes. It shows reachability, retainer paths and the shape of a leak.
Break it. In src/heap.ts, in reachable, drop the live.has(eph.key) condition so weak entries behave like strong ones. Four tests fail, among them the weak-key tests and the element-cache scenario. Revert.
Stretch. Model an AbortController that removes every listener added with its signal, and show one abort() replacing three removeEventListener calls.
Fixes compared
| Approach | Prevents | Cost | Weakness |
|---|---|---|---|
A cleanup function returned by open |
Listeners, timers, subscriptions | One exit path per feature | A forgotten registration still leaks |
AbortController owning the lifetime |
Many listeners and requests at once | Pass a signal everywhere | Needs APIs that accept a signal |
WeakMap or weak-keyed cache |
Caches keyed by an object | No enumeration, values live while the key does | Does not stop a strong reference elsewhere |
| Bounded cache (size limit, eviction) | Unbounded growth | A policy to tune | Wrong size either wastes memory or misses |
| Framework lifecycle (effect cleanup) | The above, through the framework | Learn its rules | Easy to bypass with a global |
| Scripted leak test in CI | Regressions | Test time, flakiness | Detects only the cycles you script |
Figure 3. Who outlives whom?
the thing it refers to?"} -->|"no"| OK["Same lifetime: no leak"] Q1 -->|"yes"| Q2{"A collection that
only grows?"} Q2 -->|"yes"| B["Bound it or evict"] Q2 -->|"no"| Q3{"Keyed by the object
itself?"} Q3 -->|"yes"| W["WeakMap key"] Q3 -->|"no"| C["Release in a cleanup path:
remove, clear, abort"]
How to read this: the question is always about relative lifetimes. A leak is a longer-lived holder pointing at a shorter-lived thing.
Illustration 1: the minimal case. A panel adds one anonymous resize handler to window that calls panel.fit(). Closing the panel calls panel.remove(). The handler is still in window's list, and its scope still refers to the panel. One panel is a few kilobytes, which nobody notices.
Illustration 2: the realistic case. An agent handles 200 tickets in a shift (illustrative). Each opened panel leaves behind its DOM, its dataset and a timer that keeps polling the server for a ticket nobody is looking at. After the shift the tab holds 200 dead panels, and every one of them is still running network requests. Growth is linear per panel, so the heap line is a staircase.
Illustration 3: when growth is not a leak. A tab's heap rises after the first panel opens, because code and lookup tables load once, then stays flat. That is warm-up, so do the action once before the baseline: the lab treats cycle 0 as the warm-up and compares later cycles with it. Skip the warm-up and the series "100, 133, 133, 133" is classed as noisy, not as growth, and "100, 133" is too short to judge. A cache that fills to a fixed cap also levels off; a leak keeps climbing.
In practice: a console that slowed down all shift
This is a realistic scenario, not a case history. It is a composite built from common production patterns. It describes no specific company or product, and every number is an illustrative assumption.
The situation
A support console is open for an eight-hour shift on shared laptops. Agents report that by the afternoon typing lags, charts redraw slowly, and sometimes the browser reloads the tab. Three engineers own the front end, and the first fix attempt (a smaller chart library) changed nothing.
Starting architecture
Figure 4. What a panel registers when it opens
How to read this: the panel is created and removed by the router, but it leaves three registrations in objects that live for the whole session. Each is a separate path that keeps a closed panel alive.
Options considered
| Option | What it fixes | What it costs | Risk |
|---|---|---|---|
Remove each registration by hand in close |
The three known leaks | Edits in three files | The next feature forgets |
One AbortController per panel, plus a WeakMap for the cache |
The class of leak | A convention, a small helper | Code that ignores the helper |
| Restart the tab on a timer | Hides the symptom | Lost agent state | Does not fix anything |
The decision
| Field | Decision record |
|---|---|
| Context | Memory grows linearly with panels opened; each closed panel is retained. |
| Decision | Every panel gets one AbortController; all listeners and requests take its signal; timers are cleared on abort; the layout cache becomes a WeakMap. |
| Consequences, good | One exit path, and the leak class is closed for new features. |
| Consequences, bad | Everything that registers must accept a signal. |
| Revisit when | A scripted open-and-close test still shows growth. |
Rollout and verification
- Reproduce with 50 open-and-close cycles (illustrative), forced collection, snapshot before and after, filter for detached.
- Fix and compare with the Comparison view: the six-object pattern per cycle should disappear.
- Add a regression test in an end-to-end run that opens and closes a panel repeatedly and fails if the node count after collection keeps growing.
- Check on a low-memory laptop, where growth shows first.
The 2 a.m. failure
A week later one agent still reports slowdown. A heap snapshot shows detached panels again, retained by a fetch callback. The panel's data request is slow; the agent closes the panel; the cleanup runs; then the response arrives, the .then handler calls panel.render(data) and re-registers a resize listener on the closed panel with no signal to remove it. First three checks: (1) read the Retainers pane for a detached panel; (2) look for registrations that happen in asynchronous callbacks; (3) compare timing of close and response in the logs. Mitigation: pass the panel's signal to fetch so the request is cancelled on close, and have callbacks return early if signal.aborted is true. Follow-up: a lint rule against addEventListener without signal inside panel code.
Architect's notes
- Decide object lifetimes in the design. For each feature say what owns it and when it ends. A cleanup function that returns from
openis the cheapest version of that decision. - Prefer making leaks hard to write. One helper that owns listeners, timers and requests beats a review comment.
- Bound every cache. Either a size limit, an eviction rule or a weak key. "It will not get big" is a guess.
- Test the loop, not the function. A scripted open-close test with a collection and a node count catches regressions; a unit test of one cleanup does not.
- Mind the platform you run on. Memory pressure on a low-end phone arrives long before a developer laptop notices.
Common mistakes
- Treating "removed from the page" as "freed".
- Adding an anonymous listener with no way to remove it.
- Using
WeakReforFinalizationRegistryfor cleanup that must happen. - Taking one snapshot and declaring a leak, without an undo, a collection or a second snapshot.
- Counting a warm-up cost, or a cache that levels off, as a leak.
- Fixing the leak you found and not the one behind it: asynchronous callbacks that run after cleanup.
Key takeaways
- An object is freed when it is unreachable from the roots; "no longer needed" has no meaning to the engine.
- A leak is a longer-lived holder (window, timer list, module cache) keeping a shorter-lived object reachable.
- Detached DOM nodes, uncleared timers, unremoved listeners, unbounded caches and uncancelled async work are the usual sources.
- Find leaks by action, undo, forced collection, two snapshots, the Comparison view and the Retainers pane; retained size shows what is at stake.
- Give each feature one owner of its lifetime, such as an
AbortController, and key caches weakly or bound them. WeakMapis for object-keyed caches;WeakRefandFinalizationRegistryguarantee nothing you can depend on.- A rising line after warm-up and a forced collection is evidence; a single bump is not.
Practice exercises
Exercise 1 (45 min): Write a page that opens and closes a panel with a window resize listener. Take a heap snapshot, repeat open and close ten times, take a second, and use the Comparison view to find what remained. What does the Retainers pane say?
Exercise 2 (1 hour): Fix Exercise 1 with an AbortController signal. Repeat the measurement. What does the second snapshot show now?
Exercise 3 (2 hours): In the lab, add a fourth scenario: an observer that registers a callback on a long-lived store and is never unsubscribed. Show it grows, and that an unsubscribe fixes it.
Exercise 4 (half day): Script an end-to-end test that opens and closes a view 20 times, forces a collection through your test tooling, and fails if the node count keeps rising. What cases would it miss?
Related reading
In this course:
- JavaScript Engines, JIT and Garbage Collection: how collectors work, generations and the leak basics this lesson builds on.
- React Effects and Synchronization: effect cleanup, the framework form of the cleanup function.
- Web Workers and Offloading: memory that lives in another thread.
- Layout Thrashing and Forced Reflow: another cost that grows with a long session.
- Loading Performance and Core Web Vitals: the metrics a slow, heavy tab damages.
Official documentation
- MDN: Memory management: reachability, roots, mark-and-sweep,
WeakRefcautions. - MDN: WeakMap: weak keys, no enumeration, caching limits.
- MDN: EventTarget.addEventListener(): the
signaloption and the anonymous-function note. - MDN: AbortController: aborting asynchronous operations.
- Chrome DevTools: Fix memory problems: leaks, bloat, detached nodes and the tools.
- Chrome DevTools: Record heap snapshots: shallow and retained size, retainers and the Comparison view.