skip to content

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%

answer

  1. one reading proves nothing
  2. repeat the identical round trip
  3. compare snapshots after equal batches
  4. growth per cycle, not total heap
  5. retainers path names the holder

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.

solid answer

~50 s

First make it reproducible and cheap: find one round trip — open a view, leave it, return to the same state — that can be repeated on demand. Then measure across cycles rather than at one moment. Take a heap snapshot at the baseline state, run ten cycles, snapshot again, run ten more, snapshot a third time. Chromium's Memory panel forces a garbage collection before each snapshot, so anything present survived one. Compare snapshot two against three: a cache fills and then plateaus, while a leak adds roughly the same delta every batch, often with about one new instance per cycle of something that should exist once. Then stop looking at totals and look at retention — sort by retained size, filter for detached nodes, select a growing object and read the retainers path back to a GC root. That path names the array, map, listener or timer that is holding on, and that is your fix. Confirm by re-running the same cycle count afterwards.

code

javascript · 13 lines
javascript
let created = 0;
let collected = 0;
const registry = new FinalizationRegistry(() => { collected += 1; });

function trackView(view) {
  created += 1;
  registry.register(view, null);
  return view;
}

// After navigating in and out N times, a value that keeps climbing
// alongside `created` means views are never being collected.
setInterval(() => console.log('uncollected views:', created - collected), 5000);

go deeper

for a junior

Know that memory growing is not automatically a leak, and that the check is to repeat the same action several times and see whether memory keeps rising after garbage collection.

for a middle

Explain the snapshot series: baseline, N cycles, snapshot, N more, snapshot, then compare. Be able to say what a snapshot's comparison view shows and why a delta matching your cycle count is meaningful.

for a senior

Demonstrate the full path from symptom to named reference: reduce to a round trip, compare snapshots, sort by retained size, read the retainers chain, fix, and re-run the identical cycle count to prove the delta is gone.

for a principal

Own the escalation: decide when local snapshotting stops paying and field instrumentation starts, define what memory signal your telemetry carries, and set the threshold at which heap growth blocks a release rather than becoming a ticket.

## Why one measurement proves nothing A heap that is large, or that has grown since page load, is not evidence of a leak. Long-lived apps legitimately accumulate: response caches fill, images decode, the JIT warms up, and collectors deliberately let garbage sit rather than pausing the main thread to sweep it. Memory that goes up and stays up is the *normal* shape for a healthy SPA over its first minutes. What distinguishes a leak is **monotonic growth per unit of identical work, after collection**. So the whole method is built around repeating the same work and comparing. ## Step 1 — reduce it to a round trip Find the smallest loop that returns the app to a state indistinguishable from where it started: open the dashboard, navigate away, come back. Every measurement afterwards is "per N of these". Ten cycles is usually enough to make a per-cycle leak stand out from noise; a script driving the app is better than doing it by hand because it keeps N exact. Before profiling, rule out the free explanations: an obviously growing list rendered on screen is not a leak, and neither is a cache with a documented bound that has not been reached yet. ## Step 2 — snapshot in a series, not once In Chromium DevTools' Memory panel, take a **heap snapshot** at the baseline, run N cycles, snapshot again, run N more, snapshot a third time. Taking a snapshot triggers a collection first, so anything you see survived one — this removes the biggest source of false positives. Read the series, not the numbers: - **Plateau** — snapshot 3 is close to snapshot 2. A warming cache, not a leak. - **Linear growth** — each batch adds a similar amount. A leak, and the per-cycle delta tells you how bad. - **Superlinear growth** — each batch adds more than the last. Usually a leak that also re-registers work, such as a handler added on every visit so N visits do N times the work each frame. Switch the second snapshot to the **Comparison** view against the first: it lists constructors with a delta count and a size delta, sorted so the things that appeared and were not freed float to the top. Seeing `#Delta` of exactly your cycle count next to a class of yours is as close to a smoking gun as this work gets. The **Summary** view's "objects allocated between snapshot 1 and 2" filter does the same job from the other direction. ## Step 3 — switch from totals to retention Once you know *what* is growing, the question becomes *who is holding it*. Two columns matter: **shallow size** (the object itself) and **retained size** (everything that would be freed if it became unreachable). Sort by retained size — a small object retaining a detached table is what you want to fix, and shallow size hides it completely. Select an instance and read the **Retainers** pane. It shows reference paths back to a garbage-collection root, and the shortest one is normally the bug: `window` → a listener → your view; a module-level array; a `Map` keyed by elements; a pending timer's callback. Filtering the summary for `Detached` finds the DOM half of the same story. At this point the fix is usually one line, and the hard part is over. ## Complementary tools when snapshots are unwieldy **Allocation instrumentation on timeline** records allocations continuously and marks in blue the ones still alive at the end, which is a fast way to attribute survivors to a stack rather than a class. **Allocation sampling** is lighter and safe to run for minutes. A trivially cheap alternative that needs no tooling at all: count instances yourself — increment in setup, decrement in teardown, log the difference — or register views in a `FinalizationRegistry` (standardized in ES2021) and count how many were never collected. Its timing guarantees are weak, so treat the number as a signal, not proof. ## Step 4 — carry it back to real users A local reproduction is not the whole story; real sessions run for hours with extensions, other tabs and different data volumes. In Chromium, `performance.measureUserAgentSpecificMemory()` returns an estimate of the tab's memory and can be sampled from real sessions, though it requires cross-origin isolation and resolves only when the browser chooses to measure. Pair it with your existing crash and reload telemetry: a leak that matters shows up as heap growth correlated with session length and, at the tail, as tabs being discarded. ## What a strong answer sounds like Repeatable round trip → snapshots after equal batches → read the *shape* of the growth, not one number → retained size and the retainers path to name the holder → fix → re-run the identical cycle count to prove the delta is gone. Candidates who jump straight to "I'd take a heap snapshot" and then read the total heap size have skipped every part that actually discriminates.

  • How do you tell a leak apart from a cache that simply has not filled yet?
    By running more cycles and watching the shape. A bounded cache rises and then flattens once it reaches its limit, so the third batch adds far less than the second. A leak adds a similar amount every batch indefinitely. If the code claims a bound, checking the collection's size directly settles it faster than any profiler.
  • Why sort a heap snapshot by retained size rather than shallow size?
    Because shallow size only counts the object itself, and leaks are usually small objects holding large graphs — a closure retaining a detached table, or a map entry retaining a response body. Retained size estimates what you would actually get back by cutting that reference, so it ranks candidates by the value of fixing them.
  • What would make you stop chasing a leak locally and instrument production instead?
    When the local reproduction will not grow: session length, data volume, extensions and third-party scripts all differ in the field. Sampling tab memory from real sessions and correlating growth with session length tells you whether the problem is worth more engineering time and which cohort suffers, which is often faster than more snapshots on a clean profile.

saying these in an interview costs you the question

  • Calls a large heap a leak without repeating the scenario
  • Reads total heap size instead of per-cycle growth
  • Trusts one snapshot taken without forcing collection first
  • Ignores the retainers path and guesses at the cause
  • Assumes memory that never drops is always a bug

context