Memory & GC
How the garbage collector decides what to free
The garbage collector frees unreachable objects, not unused ones. An exited scope frees nothing by itself, and neither does assigning null. Collection starts from the roots (the call stack and globals), follows every reference edge, and sweeps whatever was never reached. Everything else about memory in JavaScript is a corollary of that sentence.
let user = { name: "Ann" };
const admin = user;
user = null; // nothing is freed — admin is still a path from a root
❌ "Setting it to null frees it." Assignment cuts one edge. The object dies when the last path from a root disappears, and not a moment before. The flip side is the definition of a leak: an object you will never use again that something can still reach. The GC cannot read your intentions; anything reachable stays.
Do circular references leak memory?
function pair() {
const a = {}, b = {};
a.peer = b; b.peer = a;
} // both collected after return
Under reference counting that cycle would live forever, since each object holds one reference to the other. No modern engine counts references; they mark from roots. After pair() returns, the two objects form an island with no inbound edge: never marked, so swept as a group. "Circular references leak in JS" is a fossil from old IE, where the DOM lived in a separately refcounted system and JS↔DOM cycles genuinely stuck. An object can be referenced and still unreachable; the cycle is the cleanest proof.
Also non-negotiable: GC timing is nondeterministic and unforceable. There is no gc() in standard JavaScript; the engine collects when it decides to. Correctness can never depend on when something is collected. Reachability is the only thing you control.
Common memory leaks in single-page apps
Single-page apps leak in a handful of repeating shapes. Each is a root-reachable structure that outlives the component, and each has a recognizable smell:
- Listeners on long-lived targets.
window.addEventListener(...),document, a global emitter: the target's listener list holds your handler, and the handler's closure holds your component (the capture mechanism is the closures topic). Smell:addEventListenerin mount code with no matching removal. - Timers with captured state.
setInterval/setTimeoutcallbacks are reachable through the timer registry until cleared. Smell:setInterval(with no stored id. - Caches that only grow. A module-level
Maplives as long as the module, which means the session. Smell:cache.set(...)on a hot path, nodelete, no size bound. - Detached DOM held by JS. Removing a node from the document doesn't free it if a variable, closure, or library instance (a chart bound to an element) still references it, and one held node retains its entire subtree. Smell: elements stored in fields or arrays "for later".
- Observers never disconnected. Intersection/Mutation/ResizeObserver keep their targets registered until
disconnect()(the APIs themselves live in browser-apis). - Module-level registries of closures.
subscribe(fn)pushing into a top-level array, with nounsubscribe.
Nothing here is exotic. Every leak reduces to "long-lived structure → reference → my stuff".
When a WeakMap is the right tool
When you need to attach data to an object you don't own, a regular Map pins every key forever. WeakMap holds its keys weakly: if the map is the only thing left holding the key, the key gets collected and the entry vanishes with it.
const meta = new WeakMap();
meta.set(node, { renderCount: 3 }); // node dies → entry dies
Two things people get wrong. ❌ "WeakMap is a Map that saves memory." An entry costs the same to store either way; what changes is reachability semantics, and only for the key edge: values are held strongly while their key lives. ❌ "WeakMap is missing iteration." No size, no keys(), by design: if you could enumerate entries you could watch the collector work, and engines refuse to make GC timing observable. Consequence: keys must be objects (you can't hold a 42 weakly), so a WeakMap can't replace an id-keyed cache. That one needs a real eviction policy.
How to tear down components without leaking
The fix for the whole taxonomy is one discipline: every registration at mount has an inverse at unmount, the cleanup symmetry behind useEffect's return and onUnmounted. The modern spine for it is AbortController:
const ac = new AbortController();
window.addEventListener("resize", onResize, { signal: ac.signal });
socket.addEventListener("message", onMsg, { signal: ac.signal });
ac.abort(); // one call detaches everything registered with the signal
One signal threaded through every listener, one abort() on teardown, and you never have to keep a list of handler references to remove one by one. Intervals still need their id cleared and library instances their destroy(), but the shape is the same: teardown code should read as the mount code played backwards. Proving a leak is fixed (snapshots, retainer chains, the Detached filter) is covered in the senior notes.
How V8's generational garbage collector works
V8 (the Orinoco collector) bets on the generational hypothesis: most objects die young. The heap splits into a small young generation and a large old generation. New objects land in the nursery; a minor GC (the Scavenger) collects it by copying survivors out: live objects are evacuated to fresh memory, and everything left behind is garbage by definition. Copying costs are proportional to survivors, not allocations, so a burst of short-lived objects is close to free. Objects that survive a couple of minor GCs get promoted to old space, which is collected by a major GC: mark-sweep-compact over the whole heap.
Major GCs are the expensive ones, so V8 spreads them out: incremental marking interleaves small slices with your JS, concurrent marking and sweeping run on background threads while your code executes, and embedders schedule extra work in idle time between frames. You don't need flag trivia here, one conceptual point carries the section: engines have made allocation churn cheap and pauses rare, and what they cannot help with is retention. Mid-life objects that survive long enough to be promoted and then die make the old generation churn. A steadily growing old space (your unbounded cache) is what forces long major GCs. Short-lived is cheap; retained is expensive.
What WeakRef and FinalizationRegistry are for
WeakRef gives you a reference the collector may sever: ref.deref() returns the target or undefined. FinalizationRegistry calls you back sometime after a registered object is collected. MDN's own guidance is blunt: avoid where possible. The reasons are structural. Collection timing depends on generation, heuristics, engine, and engine version; a target is never reclaimed within the current job (you can only observe collection across turns); finalizers may run much later than you expect, or never (page close, engine discretion). So no correctness in finalizers, ever. They are for auxiliary cleanup at best, and try...finally or explicit dispose() is how real resources get released.
The one honest use case: a cache of large, recomputable values, where "the engine threw it away under pressure" is a fine outcome:
const cache = new Map(); // key → WeakRef(bigThing)
const hit = cache.get(key)?.deref();
if (!hit) cache.set(key, new WeakRef(recompute(key)));
Even then you pair it with a FinalizationRegistry solely to delete stale map entries, and the code must be indifferent to when any of this happens. If you find yourself wanting deterministic weak behavior, you want WeakMap (if the keys are objects) or an explicit eviction policy instead.
Finding memory leaks with DevTools heap snapshots
Triage first: memory that climbs across repetitions of an action is a leak; memory that is flat but high is bloat, and the two call for different investigations. For leaks:
- Three snapshots. Baseline snapshot → perform the suspect action (e.g. navigate to the dashboard and away, a few times) → snapshot → repeat → snapshot. In the comparison view, look at objects allocated between snapshots 1 and 2 that are still alive in 3. Anything that scales with the repeat count (one chart instance per navigation) is your leak. Crucial detail: taking a snapshot forces a full GC first, so everything you see is genuinely reachable. There is no "it just hasn't been collected yet" excuse in a snapshot.
- Retainer chains. Select a leaked object and read the Retainers pane: the path from a root that keeps it alive. Walk it until you hit a frame you own, say
appEvents → listeners → closure → chart → div. That line is the diagnosis; the fix is severing the edge you control. - Detached DOM. Filter the class list by "Detached" (or run the Detached Elements profile) to find nodes removed from the document but still referenced from JS, each retaining its whole subtree. After a correct teardown this list should not grow with navigations.
- Allocation timeline / sampling. When you don't know what allocates: the allocation timeline shows bars per allocation burst (blue = still alive), attributable to stack traces; allocation sampling gives a cheap by-function profile for longer sessions.
One tool is missing from this list on purpose. The Performance panel's memory graph answers the symptom question (does the heap ratchet upward across forced GCs?) and nothing more; perf tracing proper lives in rendering-performance.
Memory budgets on real devices
Your dev machine has 32 GB and lies to you. On low-end Android, the OS kills tabs at heap sizes a desktop wouldn't blink at, and iOS Safari is stricter still. A dashboard that "only" leaks 2 MB per navigation is a crash report on a phone kept open all day. So treat memory as a budget per route or feature, and measure in the field as well as in DevTools: performance.measureUserAgentSpecificMemory() (available on cross-origin-isolated pages) samples real users' memory and lets you alert on a regression, the memory equivalent of RUM. Once a leak shows up on a release-over-release graph, it gets prioritized and fixed.
How interviews frame memory questions
Senior memory questions rarely come as "explain mark-and-sweep". They come as post-mortems: "memory climbs, walk me through it." What gets graded is the story arc: symptom → hypothesis from the taxonomy (listeners on long-lived targets, timers, growing caches, detached DOM, undisconnected observers) → snapshot proof with a retainer chain naming the exact edge → fix (AbortController-spined teardown, bounded cache, destroy()) → regression guard. Two claims carry the most weight: the GC frees only what is unreachable, so the question is always who can still reach the object; and snapshots force a GC, so whatever a snapshot shows is genuinely retained. Interviewers weight one concrete leak from your own work, told in this shape, above any amount of theory.
beta
The interviewer part is in the works.
The diagnostic, personal maps, and AI mock interviews are being finished right now. The notes stay free either way. Leave an email and you'll get the first-cohort invite, plus a month of Pro when it opens.