Skip to content
← Journal
12 min readAta Mohammadi

Senior Frontend Interview Prep, Part 2: Memory, Garbage Collection and Leak Topologies

Fifteen interview questions on the V8 heap: generational collection, the scavenger, concurrent marking and write barriers, and the five leak shapes behind almost every 'the tab is using two gigabytes' report.

Every frontend engineer has been handed the bug that says "the app gets slow after about twenty minutes". It is almost never slow code. It is memory that nothing is allowed to release, and at senior level you are expected to be able to find it without guessing.

This is part two of the series. Part one covered how V8 turns source text into machine code; this one covers where the objects go afterwards, and why they sometimes never leave.

Generational collection

16. What is the generational hypothesis, and how does V8 exploit it?

The hypothesis is an empirical observation, not a theorem: most objects die young. In a typical program the overwhelming majority of allocations are intermediate values that become garbage within microseconds, while a small minority survive for the lifetime of the process.

If you collect the whole heap every time, you pay to re-examine the long-lived minority over and over. So V8 splits the heap:

  • Young generation (the nursery) — a few megabytes, typically between 1 and 64 MB depending on the device. Collected by the scavenger, which runs often and finishes in single-digit milliseconds.
  • Old generation — everything that survived the nursery twice. Collected by the major GC, which runs rarely and works hard to stay off the main thread.

There are other spaces worth naming if the interviewer pushes: code space, map space (hidden classes), and large object space, which we come back to in question 29.

17. How does the scavenger work?

The nursery is two equal halves: from-space and to-space. New objects are allocated by bumping a pointer through from-space, which is about as cheap as allocation gets.

When from-space fills:

  1. The collector walks the roots — the stack, globals, handles — to find live objects in from-space.
  2. Each live object is copied into to-space, and a forwarding pointer is left behind so other references to it can be updated.
  3. Dead objects are not touched at all. No free, no destructor, no bookkeeping.
  4. An object that has now survived twice is promoted into the old generation instead.
  5. The two spaces swap roles, and from-space is empty again.

This is Cheney's algorithm, and the property to state out loud is the one that surprises people: the cost is proportional to what survived, not to what was allocated. Allocating a million temporary objects is close to free. Keeping a thousand of them alive is not. That inverts the usual instinct to "reduce allocations" — what actually matters is reducing survival.

18. What are the phases of the major GC?

  • Mark. Trace the object graph from the roots, setting a bit per live object in a marking bitmap.
  • Sweep. Walk the unmarked regions and thread them onto free lists bucketed by size, so the allocator can reuse them.
  • Compact. For pages that are mostly holes, evacuate the survivors into fresh contiguous pages and update every pointer that referred to them.

Sweeping makes memory available; compaction makes it contiguous. Those are different problems, which is why both exist.

19. How does concurrent marking work, and what is the write barrier for?

Under Orinoco, marking mostly happens on background threads while your JavaScript keeps running. That immediately creates a correctness hazard: the mutator can rearrange the graph underneath the collector.

The dangerous case is precise. The collector has already finished with object A and marked it black. Your code then stores a reference to a brand-new, unmarked (white) object B into a field of A. The collector will never revisit A, so B is never marked — and gets swept while it is live.

The fix is the write barrier: every write of a reference into a heap object is intercepted, and the newly referenced object is pushed onto the marking worklist.

Thread Doing Guarantee
Main Running your JavaScript, mutating references Every reference write goes through the barrier
Workers Draining the marking worklist, setting bitmap bits Never blocks the main thread
Main Barrier fires on a write The new referent is enqueued before the write is visible

Two consequences worth mentioning. The barrier is why writing references is measurably more expensive than writing numbers, and why an array of Smis is cheaper to fill than an array of objects. And the collector still needs short stop-the-world pauses at the start and end of a cycle — concurrent marking shortens pauses, it does not abolish them.

20. What is old-space fragmentation, and what does the compactor do?

Allocation, promotion and sweeping over time leave free space scattered across pages in small gaps. You can have 200 MB free and still fail to allocate a 4 MB contiguous buffer, because no single gap is big enough — so the heap grows instead, and now you have 200 MB free and a bigger heap.

The compactor picks the most fragmented pages, evacuates their survivors into fresh pages, fixes up every pointer to the moved objects, and returns the emptied pages to the OS. It is the expensive part of a major GC and the reason "memory went down in a step" looks the way it does in a memory graph.

Leak topologies

Five shapes account for nearly everything you will ever debug.

21. Detached DOM trees

You call element.remove(). The node leaves the document. But some JavaScript still holds a reference — an array of "cached rows", a closure, a stale ref — so V8 cannot collect the JS wrapper, and Blink cannot collect the C++ node behind it.

const rowCache = new Map();

function renderRow(data) {
  const row = document.createElement("tr");
  rowCache.set(data.id, row); // ← the leak
  return row;
}

function clearTable(tbody) {
  tbody.replaceChildren(); // the rows leave the document…
  // …but rowCache still points at every single one of them.
}

In a DevTools heap snapshot they show up under constructors named Detached HTMLTableRowElement and friends. The tell is the size columns: small shallow size, enormous retained size, because holding one detached node holds its entire subtree.

The fix is almost always ownership: whoever removed the node clears the cache, or the cache holds keys rather than nodes.

22. Retained lexical closures — the expensive one

This is the leak that senior candidates are expected to explain mechanically, because the mechanism is not obvious.

Sibling closures declared in the same scope share one context object. A variable is allocated into that context if any inner function references it. So a long-lived closure that references one tiny variable keeps the whole context alive — including large values it never touches.

function setupStreamCoordinator() {
  const heavy = loadLargeDataset(); // referenced only by the function below
  const meta = { streamId: "stream_901" };

  function inspect() {
    return heavy.length;
  }
  void inspect;

  // This is the only thing that escapes. It reads `meta.streamId`.
  // It also pins `heavy`, because both live in the same context.
  return () => meta.streamId;
}

Measured, so it is not a story. Five of those tiny closures, each referencing nothing but a short string:

node --expose-gc closure.mjs
shared   5 tiny closures retain 38.1 MB
isolated 5 tiny closures retain  0.0 MB

The "isolated" variant does exactly the same work, with the large allocation confined to an inner block that returns before the surviving closure is created. The difference is 38 MB and one pair of braces.

Two reliable fixes: give the big value its own scope so it is not in the survivor's context, or null the binding explicitly once you are done with it. The second is uglier and works.

23. Global listeners pinning unmounted components

window.addEventListener("resize", handler) creates a reference from a GC root. If handler is a bound method or a closure over component state, the whole component graph — state, refs, DOM nodes — is now rooted, and unmounting changes nothing.

class Chart {
  constructor(data) {
    this.data = data; // several MB
    this.onResize = () => this.redraw();
    window.addEventListener("resize", this.onResize);
  }

  destroy() {
    // Without this line the instance is immortal.
    window.removeEventListener("resize", this.onResize);
  }
}

The modern version removes the need to keep the handler reference at all:

const controller = new AbortController();

window.addEventListener("resize", () => redraw(), { signal: controller.signal });
socket.addEventListener("message", onMessage, { signal: controller.signal });
document.addEventListener("visibilitychange", onVisibility, { signal: controller.signal });

// One call detaches all three.
controller.abort();

One AbortController per component, aborted on teardown, is the pattern to name. It composes with fetch too, which is why it is worth making the default rather than a special case.

24. WeakMap versus Map

A Map holds its keys and values strongly. While the map is reachable, everything in it is reachable — which makes "cache keyed by DOM node" a memory leak with extra steps.

A WeakMap holds its keys weakly. A key's presence in the map does not keep it alive; when the last other reference to the key goes away, the entry disappears with it.

// Per-node metadata that cannot outlive the node.
const nodeState = new WeakMap();

function attach(node, state) {
  nodeState.set(node, state);
}

And the detail that shows you understand why: a WeakMap has no size, no iteration, no keys(). It cannot have them. Exposing its contents would make garbage-collection timing observable from JavaScript, which would turn a scheduling decision into program semantics.

25. Why FinalizationRegistry is not a cleanup mechanism

It looks like a destructor. It is not one.

  • The specification guarantees nothing about when — or whether — a callback runs. A collection may never happen if memory pressure stays low.
  • On tab close, navigation or crash, pending callbacks are simply dropped.
  • Callback ordering and timing vary by engine, by build and by memory conditions, which makes any bug involving them unreproducible.

Use it for optional hygiene — clearing an entry in an off-heap side table, logging a diagnostic about a resource that was not closed properly. For anything that must happen, use an explicit lifecycle: try/finally, a dispose() method, AbortSignal, or the using declarations from explicit resource management. "I'd use FinalizationRegistry to detect the bug, and a dispose() to fix it" is the answer that lands.

26. Cycles between JavaScript objects and DOM nodes

Historically this was fatal. Old IE used reference counting for DOM nodes and tracing for JavaScript objects, so a JS object pointing at a DOM node whose onclick pointed back at the JS object formed a cycle that crossed the boundary between two collectors, and neither could ever free it. Entire libraries existed to break those cycles by hand.

Modern engines trace across the boundary. Blink's Oilpan collector is integrated with V8 through cross-component tracing, so a cycle spanning both heaps is traced as one graph and collected as one unit. The practical upshot: cycles are no longer a leak class in themselves. If something is not being collected today, there is a real path from a root — you just have not found it yet.

27. Shallow size versus retained size

  • Shallow size — the bytes of the object itself: its header, its map pointer, its in-object fields. Pointers count as pointers, not as what they point at.
  • Retained size — the bytes that would be freed if this object vanished. Its shallow size plus everything reachable only through it.

The whole technique of leak hunting is in the gap between those two numbers. A closure with a 56-byte shallow size and a 40 MB retained size is not a big object; it is the doorway to a big object graph, and it is the thing to fix. Sort by retained size, ignore shallow size, and follow the retainers pane to the root path.

28. How console.log leaks in production

The console keeps its arguments live so you can expand them later. It does not snapshot or stringify them.

In a development tab that is a feature. In a long-running production session — an embedded WebView, a kiosk, a headless browser, anything where the console history is retained — logging objects inside a high-frequency loop pins every one of them. They survive two scavenges, get promoted to the old generation, and stay there.

Strip logs in production builds, or log primitives (console.log(user.id), not console.log(user)), or route through a logger that serialises immediately and drops the reference.

29. Large object space

Anything above roughly a page's worth of memory — big typed arrays, long strings, large literal arrays — is allocated directly into large object space, bypassing the nursery entirely.

Two reasons. Putting a 50 MB buffer in a 16 MB nursery would trigger a scavenge immediately. And copying it — which is what a scavenge does to survivors — would cost more than the allocation itself. So LOS objects are never moved: they are swept in place during major GCs, and their pages are returned to the OS individually.

The practical consequence for a frontend engineer is about ArrayBuffer in particular. The backing store of a typed array lives outside the JavaScript heap, so performance.memory and heapUsed will not show it. If you are measuring memory in a media or WASM-heavy app and the numbers look implausibly good, that is why — you need measureUserAgentSpecificMemory() or the browser's task manager, not a heap number.

30. Keeping a high-frequency stream from leaking

Four habits, in the order they pay off:

  1. One AbortController per lifecycle. Every listener, every fetch, every observer gets the same signal. Teardown is one call, which means teardown cannot be partially forgotten.
  2. Bound every buffer. Telemetry history, message logs, undo stacks — use a fixed-capacity ring buffer, never an array you only ever push to. An unbounded buffer is not a leak, it is a leak you decided to build.
  3. Pass values, not scopes. Handing a callback a primitive instead of an object that closes over a stream keeps context graphs from crossing component boundaries.
  4. Null the roots on teardown. Explicitly dropping references to sockets, workers and observers costs one line and removes a whole class of "why is this still alive" investigation.

How to practise

Open the app, take a heap snapshot, do the thing five times, take another snapshot, and diff them with the Comparison view. Objects with a positive delta after five identical cycles are your leak; sort by retained size and read the retainer chain from the bottom up until you find the one edge that should not be there.

Do that once on a real app and questions 21 to 30 stop being a list you memorised.


Next: the event loop — microtasks, macrotasks, requestAnimationFrame, and why INP measures something none of them do on their own.

Next step

Tell us what is broken or what should exist.

Send the shape of the problem and any constraints you already know — budget, deadline, the stack you are stuck with. You will get a written reply from the engineer who would do the work, not a sales sequence.