DebuggingBROWSER-SPECIFICENGINE-SPECIFICDEVICE-SPECIFIC

Debugging Memory

Three snapshots across a repeated cycle, then follow the retainer chain to whatever is holding the thing that should have gone. Growth is not a leak until you can say what is keeping it alive.

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.

The question

Memory keeps climbing in this tab — is something leaking, or is a cache doing exactly what it was asked to do?

The user intent

Someone has had the app open all day. It was fine this morning; now scrolling stutters, typing lags, and eventually the tab reloads itself and loses their work.

The obvious build

Watch the memory number. It goes up, so something is leaking; find the biggest object in a heap snapshot and stop allocating it.

Why it breaks

Memory going up is the normal behaviour of a running program. Allocation happens continuously and collection happens when the engine decides, so a rising line between collections says nothing at all (Memory Leaks).

How it breaks in a real browser
  • Memory going up is the normal behaviour of a running program. Allocation happens continuously and collection happens when the engine decides, so a rising line between collections says nothing at all (Memory Leaks).
  • The biggest object is rarely the bug. A leak is usually many small objects retained by one reference — a listener, a closure, an array that only ever grows — and its total is spread across thousands of entries.
  • A snapshot taken once includes everything not yet collected plus everything devtools and the console are holding. Without a comparison the number is uninterpretable (A Mental Model of the Devtools).
  • A cache doing its job looks exactly like a leak for as long as nothing has asked it to evict. The difference is not the shape of the curve; it is whether the growth is bounded (The Client Cache Model).
  • The most common frontend leak is not JavaScript objects at all — it is detached DOM: nodes removed from the document that something still references, dragging their entire subtree along with them (Detached Nodes and What Keeps Them Alive).
IntentEventStateUI LogicDOM WorkNetworkLayout / PaintPixelsFeedback

What is actually happening

In the browser, not in the framework.

  • The JavaScript heap is a graph. The collector keeps anything reachable from a root — the global object, the current stack, the DOM tree, running timers, registered listeners — and reclaims everything else. Nothing is "freed"; things simply stop being reachable (The Browser Is a Runtime).
  • A leak is therefore not a failure to free. It is an unintended reference: a path from a root to something you believed was gone. Debugging memory means finding that path, which is what a retainer chain is.
  • Detached DOM is the characteristic frontend case. A node removed from the document is still an object; if any JavaScript reference survives — a cached element, a closure in a listener, an entry in a map keyed by node — the node and everything beneath it stays alive (Detached Nodes and What Keeps Them Alive).
  • Listeners and subscriptions are the usual referrer. A handler registered on window, on document, on an event bus, on an observer or on a store holds its closure, and that closure holds whatever was in scope when it was created — frequently an entire component subtree (How an Event Is Dispatched).
  • Retained size is what would be freed if this object went away; shallow size is the object itself. A closure with a shallow size of nothing can have a retained size of a whole view, which is exactly why sorting by shallow size finds nothing.
  • A single-page application never unloads the document, so every navigation is an opportunity to accumulate rather than a reset. That is the structural reason this class of bug belongs to frontend engineering specifically (Client-Side Routing).

What this makes the browser do

And which of it is avoidable.

  • Taking a snapshot forces a collection and walks the whole heap, which pauses the page — so the act of measuring changes both the number and the responsiveness you are trying to assess.
  • The console retains what it shows. A logged object, a stored $0 selection, or an expanded tree keeps its graph reachable, and that is enough to invent a leak that only exists while devtools is open.
  • Detached nodes still cost more than their objects: they may retain event listeners, observers, and in some engines rasterised content associated with them.
  • Under memory pressure the browser will discard backgrounded tabs entirely, which on a low-memory phone presents to the user as "the app reloaded and lost my form" rather than as a memory problem (Long-Lived Clients and Version Skew).

The three-snapshot method

The whole method exists to answer one question: does memory that should have been released survive a cycle that returns the application to the same state? A cycle is anything with a beginning and an end — open a dialog and close it, navigate to a route and back, filter a list and clear the filter. If the app is in the same state and the heap is not, something from that cycle is still reachable.

Repetition is what makes this rigorous. One cycle produces noise; several identical cycles produce a slope. A leak shows as growth proportional to the number of cycles, which is both unmistakable and, usefully, a way to estimate how bad it is: memory per cycle multiplied by cycles per hour is the shape of the user's afternoon.

Three snapshots, and how each step is botched
  1. 1
    Settle

    Loads the app, exercises the route once to warm caches and lazy chunks, then leaves it idle briefly so one-off allocations are done (Lazy Loading).

    fails by Snapshotting immediately after load, so first-use allocation is counted as growth.

  2. 2
    Snapshot 1 — baseline

    Records the heap in the state the cycle will return to.

    fails by Taking it with objects still referenced from the console, which pollutes every later comparison.

  3. 3
    Run the cycle

    Performs the suspected action and returns to exactly the baseline state — open and close, navigate away and back.

    fails by Not returning to the same state, so the comparison is between two different applications.

  4. 4
    Snapshot 2

    Records the heap after one cycle. On its own this is only a hint.

    fails by Stopping here and declaring a verdict from one delta.

  5. 5
    Repeat the cycle several times

    Runs the identical cycle again and again, which turns a single ambiguous delta into a slope.

    fails by Varying the cycle between repetitions, which makes the growth uninterpretable.

  6. 6
    Snapshot 3

    Records the heap after repetition. Growth proportional to the number of cycles is a leak; growth that flattens is a cache filling up (The Client Cache Model).

    fails by Comparing to snapshot 1 only, missing whether growth is linear or bounded.

  7. 7
    Compare deltas

    Lists objects allocated between snapshots and still alive, sorted by retained size.

    fails by Sorting by shallow size, which hides the closure holding an entire view behind a very small object.

  8. 8
    Follow the retainer chain

    Traces one representative object back to a root and names the exact reference keeping it alive.

    fails by Guessing from the object's type instead of reading its chain — the type tells you what leaked, never who is holding it.

  9. 9
    Fix and re-run the same cycle

    Repeats the whole measurement, unchanged, and shows the slope is gone (A Method for Frontend Bugs).

    fails by Verifying with a different cycle, or on a page that has not been reloaded since the fix.

The discipline is comparison. Every step exists to make one number mean something relative to another number taken under the same conditions.

Retainer chains and detached DOM

A retainer chain answers the only question that matters: which reference is keeping this alive. It reads backwards from the object to a root, and the fix is always somewhere on that path. This is why sorting a snapshot by size is such an unproductive habit — the size tells you what is being kept, and the chain tells you who is keeping it.

The frontend-specific case is detached DOM. A node removed from the document is garbage only if nothing references it, and a listener whose closure captured it is a reference. Because a node retains its whole subtree, a single captured element can hold thousands of nodes, their listeners and any data attached to them — which is how a leak of one variable becomes a leak of an entire view (Detached Nodes and What Keeps Them Alive).

A retainer chain, read backwards from the leak
reachableholdscapturedsubtreecapturedcuts the chain hereGC root: windowremoveEventListener(onResize)resize listenerclosure scopedetached <ul>rows: Row[]5,000 <li> + listeners
UserLLMAgentToolDataDecisionHumanGuardrail
One captured element, an entire view retained
1function mountPanel(root: HTMLElement, rows: Row[]) {
2 const list = document.createElement('ul')
3 render(list, rows) // thousands of nodes
4 root.append(list)
5
6 // The closure captures `list` and `rows`. Registering on window means
7 // the listener is reachable from a root for as long as the page lives.
8 window.addEventListener('resize', () => relayout(list, rows))
9
10 return () => root.removeChild(list) // removes from the document, not from memory
11}
12
13// window (root)
14// -> resize listener
15// -> closure
16// -> list (detached <ul>) -> every <li> beneath it
17// -> rows (the data too)
18
19function mountPanelFixed(root: HTMLElement, rows: Row[]) {
20 const list = document.createElement('ul')
21 render(list, rows)
22 root.append(list)
23
24 const onResize = () => relayout(list, rows)
25 window.addEventListener('resize', onResize)
26
27 return () => {
28 window.removeEventListener('resize', onResize) // same reference, or it is a no-op
29 root.removeChild(list)
30 }
31}

Two details do the damage in the first version. The listener is on window, so it is reachable from a root regardless of what happens to the panel; and it is an inline function, so even a teardown that tried to remove it could not name it. removeEventListener with a different function reference fails silently, which is why so many "fixed" leaks are not (Detached Nodes and What Keeps Them Alive).

A leak, or a cache doing its job?

Not all growth is a bug, and treating it as one produces the second-worst outcome available: a cache deleted, a hit rate lost, and the actual leak still shipped. The distinguishing property is not the shape of the first few minutes — both curves rise — it is whether growth is bounded and whether it is attributable to a policy someone chose.

Walk the rows below against your own measurement. The last two are worth internalising, because they are where investigations most often go wrong: growth that only appears with devtools open is usually your own console references, and growth in DOM node count with a flat heap is detached DOM or an ever-growing list, not an object leak (List Virtualization).

What you observe across repeated cyclesMost likely explanationHow to confirmWhat to do
Grows linearly with cycles, never flattensA genuine leak: one unintended reference per cycleRetainer chain on a representative object leads to a root that should not hold itCut the chain — remove the listener, clear the timer, disconnect the observer (Memory Leaks)
Grows quickly, then flattens at a ceilingA bounded cache filling up to its limitThe ceiling matches the configured bound; entry count stops risingNothing. Confirm the bound is appropriate for a low-memory device (The Client Cache Model)
Grows steadily and never flattens, all in one structureAn unbounded cache — a leak with a respectable nameOne map, array or store keyed by something that never repeatsGive it a size limit and an eviction policy (Query Keys and Invalidation)
Heap flat, DOM node count climbingDetached DOM, or a list that only ever appendsThe detached-node view lists nodes with retainersFix teardown, or window the list so node count is bounded (List Virtualization)
Listener count climbing with navigationsSubscriptions registered on mount without removalCompare listener counts across identical cyclesPair every registration with a removal that uses the same reference (Detached Nodes and What Keeps Them Alive)
Only grows while devtools is openConsole references retaining what you loggedClear the console, take a fresh baseline, repeatNothing in the app. Re-measure with the console clear (A Mental Model of the Devtools)
Grows only on one routeA component on that route with a teardown bugBisect by route; the cycle that grows names the component (A Method for Frontend Bugs)Fix that component's cleanup, then re-run the same cycle
Flat locally, tabs discarded in the fieldA device-class problem: the leak is small, the device is not generousField data correlating session length with reloads (Release Health)Reproduce with a real low-memory device before concluding there is nothing to find

How to build it

Most important first.

  • Use the three-snapshot method. Snapshot at a baseline, perform the cycle you suspect, return to the baseline state, snapshot again, repeat the cycle several more times and snapshot a third time. Growth that persists across repetitions of a cycle that returns to the same state is the definition of a leak (Memory Leaks).
  • Compare, never read absolutes. The delta between snapshots — objects allocated in the interval and still alive — is the only interpretable number here.
  • Follow the retainer chain to a root. The chain names the exact reference keeping the object alive, which is the fix. Anything short of that is a guess about what to delete.
  • Look for detached DOM explicitly. It is a distinct category in most tools and it is where the largest wins usually are (Detached Nodes and What Keeps Them Alive).
  • Check the obvious owners first: listeners without removal, timers and intervals never cleared, observers never disconnected, subscriptions without unsubscribe, and caches keyed by something unbounded (What a Mutation Costs).
  • Close devtools and stop logging before you trust a measurement, and take the confirming reading with the console clear (A Mental Model of the Devtools).
  • Bound every cache you own, deliberately, with a size or an eviction policy — an unbounded cache is a leak with a benign name (The Client Cache Model).
  • Verify in the field, not only locally: growth per session over time by route is what tells you whether the fix reached anyone (Real User Monitoring).

Keyboard, focus, semantics, announcement

A required field on every lesson in this domain, not a section added when there is room.

  • Memory pressure is felt as unresponsiveness, and an unresponsive main thread delays focus changes and announcements exactly as it delays pixels — with no visual cue that the page is busy (What the Main Thread Owns).
  • A tab discarded under memory pressure and restored loses focus position and any unsaved form state. For someone who navigates by keyboard, being returned to the top of a long document is a significant loss of place (Focus Management).
  • Detached DOM sometimes retains more than memory: an incorrectly torn-down dialog can leave stale nodes that assistive technology can still reach, so a screen-reader user hears content that is no longer visible to anyone else (The Accessibility Tree).
  • Verify a teardown fix with a keyboard: tab through after closing the component and confirm focus cannot reach anything that was supposed to be removed (Keyboard Operability).
  • Live regions are a common leak of a different kind — a region left registered across navigations accumulates announcements from views the user has already left (Live Regions and Announcement).

What can go wrong

Failure modes
  • Declaring a leak from one snapshot, or from a rising graph between collections.
  • Finding growth that is your own console references — the classic self-inflicted investigation.
  • "Fixing" a leak by clearing a cache on every navigation, which removes the growth and the benefit of the cache together (Stale-While-Revalidate).
  • Removing a listener with a different function reference than the one that was added, so the removal silently does nothing and the leak survives the fix.
  • Nulling a reference that was never the retainer, then finding the memory still grows because the chain went through somewhere else entirely.
  • Fixing the leak and not the symptom: a tab that has already grown for hours does not recover because the next navigation is clean.
  • Debugging on a machine with abundant memory, where the failure that users experience — discarded tabs and reloaded pages — never happens (Frontend Error Tracking).
What can arrive out of order
  • Collection is non-deterministic. Two identical runs can show different heap sizes purely because the engine collected at a different moment, which is why single measurements and comparisons of absolutes both mislead.
  • A teardown that races an in-flight response can re-register a subscription after the cleanup ran, reviving exactly the reference you removed (Cancelling a Request Nobody Is Waiting For).
  • A navigation during an animation or a pending timer can leave the callback holding a view that has already been replaced (Client-Side Routing).
  • A snapshot forces a collection, so taking one can hide a retention that would have been visible under normal pressure, and can change the result of the very comparison you are making.
Security
  • Heap snapshots contain everything the page held in memory: tokens, personal data, request bodies, and the contents of forms. They are a data export, and they get attached to tickets like screenshots (Session Replay and the Privacy It Costs).
  • Long-lived memory extends the window in which sensitive values exist in a tab. Clearing a credential from state does not clear it from every copy, but it does reduce how long it is reachable (Cookies vs Script-Readable Tokens).
  • Memory exhaustion is a denial of service against your own users: a leak triggered by a page an attacker can cause a user to open is a real, if unglamorous, availability bug (The Browser Security Model).
  • Timing and memory measurement APIs are deliberately coarse or restricted in browsers because precise ones have been used to attack cross-origin state. Devtools shows you more than script can, and that asymmetry is intentional (The Same-Origin Policy).
Misreads
  • "Memory went up, so it leaks." Memory going up between collections is what a running program does. Growth that survives repeated identical cycles is what a leak does (Memory Leaks).
  • "The heap looks flat, so there is no leak." Detached DOM and listener accumulation can be modest in bytes and severe in effect, and DOM node count is a separate signal from heap size (Detached Nodes and What Keeps Them Alive).
  • "Setting it to null frees it." It removes one reference. If the retainer chain runs through a listener, a closure, a timer or a map, the object is still reachable.
  • "The cache is the leak." Sometimes; more often the cache is bounded and doing its job while an unrelated listener holds a view. Confirm with a retainer chain before deleting a cache someone added for a reason (The Client Cache Model).
  • "It only grows with devtools open." Then it is very likely your own console references — which is a real finding, just not about your app.

Measuring it, and what changes in the field

How you would see this
  • Heap snapshots compared across repeated cycles, read as deltas and sorted by retained size rather than shallow size (Memory Leaks).
  • The detached-node view, which turns "something is holding DOM" into a list of the nodes and their retainers (Detached Nodes and What Keeps Them Alive).
  • An allocation timeline for finding what is allocating in a hot path, as distinct from what is being retained (Allocation Rate Is a Cost Even Without a Leak in Observability).
  • A lightweight performance monitor for a live view of heap size, DOM node count and listener count while you exercise the app — the cheapest early warning there is (A Mental Model of the Devtools).
  • In the field, session length correlated with crashes and reloads, since a real leak shows up as tabs being discarded rather than as a metric anyone reports (Release Health).
Slow device, slow network, large data, old tab
  • On a low-memory device the same leak is fatal rather than gradual: the browser discards the tab, and the user experiences a reload that loses their work (Memory Pressure, Swap and the OOM Killer in Operating Systems).
  • In a long session, small per-interaction retention compounds. Behaviour after an hour is a different question from behaviour on load, and only the first is ever tested (Long-Lived Clients and Version Skew).
  • With a large dataset, a bounded cache can still be too large — bounded by entry count, unbounded in bytes, is a common and expensive mistake (List Virtualization).
  • In a single-page application, navigations accumulate; in a multi-page application each navigation is a fresh document and most of this class of bug simply does not exist (MPA vs SPA).
  • On a page with third-party script, some of the retention is not yours and cannot be fixed by you — only measured, and used as an argument (Third-Party Scripts and the Supply Chain).
What this costs
  • The three-snapshot method is slow and deliberate: several cycles, several pauses, and careful comparison. It is the only method that distinguishes a leak from ordinary allocation, and every shortcut reintroduces the ambiguity it exists to remove.
  • Bounding caches costs hit rate. An eviction policy makes memory predictable and makes some interactions slower, and that trade should be made explicitly rather than discovered.
  • Defensive teardown — removing every listener, disconnecting every observer, cancelling every timer — is code that exists purely to prevent retention. It is worth it, and it is a real maintenance cost with no visible feature attached.

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.

  • BROWSER-SPECIFICHeap snapshots, retainer views, detached-node listings and allocation timelines exist in different forms across Chromium, Firefox and Safari: the categories are named differently, the retainer view is presented differently, and detached DOM is surfaced explicitly in some tools and only findable by search in others. The method transfers; the buttons do not.
  • ENGINE-SPECIFICWhen collection happens, how generational collection groups objects, and what counts toward the reported heap are engine decisions in V8, SpiderMonkey and JavaScriptCore. Two engines can report meaningfully different numbers for the same page without either being wrong, so never compare an absolute across browsers.
  • DEVICE-SPECIFICConsequence depends on the device: on a desktop with abundant memory a leak degrades slowly, while on a low-memory phone the browser discards the backgrounded tab and the user sees a reload that loses their work. The same bug therefore produces two completely different reports.

Where the depth lives

This domain teaches the browser-side mechanism and hands the rest off.

Concurrencyorphaned-tasks
Domains that do not exist yet
  • Programming Languages & Runtime Internals — how generational and incremental collectors decide when to run, what a write barrier costs, and why a pause you can feel is a scheduling decision rather than a leak.
  • Testing & Reliability Engineering — running a cycle many times in an automated environment and asserting a bound on retained memory, so that a teardown regression fails a build rather than a support ticket.