Memory and Garbage Collection
TL;DR
JS memory is reclaimed by a garbage collector based on reachability: an object is kept alive while it’s reachable from a GC root (the global object, the call stack, active closures). You don’t free memory manually — you make objects unreachable. Leaks come from accidental retention: forgotten timers/listeners, detached DOM nodes held in JS, ever-growing global caches, and closures capturing more than they need. WeakMap/WeakSet/WeakRef/FinalizationRegistry let you reference objects without preventing their collection.
Interview Q&A
Q: How does GC decide what to free?
A: Reachability, not reference counting. Starting from roots (globals, the current call stack, live closures), the collector marks everything reachable; the unmarked rest is swept. Modern engines (V8) use a generational mark-and-sweep: most objects die young, so a small “young generation” is collected often and cheaply, survivors are promoted to the old generation collected less often. Because it’s reachability-based, reference cycles are fine — two objects pointing at each other are still collected if nothing else reaches them.
Q: What are the common memory leaks in a web app?
A:
- Forgotten timers/intervals —
setIntervalwhose callback closes over a large object and is never cleared. - Dangling event listeners —
addEventListenerwithoutremoveEventListener; the handler (and its closure) outlives the component. - Detached DOM — you remove a node from the document but keep a JS reference (
this.node = el), so the whole subtree stays in memory. - Unbounded caches/maps — a module-level
Mapyou only eversetinto. - Closures capturing too much — see closures.md; a returned function pins its entire enclosing scope.
- React-specific: subscriptions/timeouts not cleaned up in
useEffect’s return — see ../05_react/use_effect_deep.md.
Q: Show a leak and its fix.
A:
// LEAK — interval keeps `data` alive forever, even after we're "done"
function start() {
const data = new Array(1e6).fill(0);
setInterval(() => console.log(data.length), 1000);
}
// FIXED — keep the handle, clear it
function start() {
const data = new Array(1e6).fill(0);
const id = setInterval(() => console.log(data.length), 1000);
return () => clearInterval(id); // call when done → data becomes unreachable
}
Q: When do you use WeakMap / WeakSet?
A: To associate metadata with an object without keeping that object alive. Keys are held weakly — if the key object is otherwise unreachable, the entry is collected automatically:
const meta = new WeakMap();
function tag(node) { meta.set(node, { lastSeen: Date.now() }); }
// when `node` is removed and unreferenced elsewhere, its entry is GC'd — no manual cleanup
Perfect for caches keyed by object, private data, and DOM-node bookkeeping. WeakMap/WeakSet are not iterable (you can’t enumerate weak keys — they might vanish at any time).
Q: WeakRef and FinalizationRegistry?
A: Lower-level escape hatches (ES2021):
WeakRef— holds a weak reference you.deref()to get the object orundefinedif collected. For caches where you tolerate the value disappearing.FinalizationRegistry— register a cleanup callback to run after an object is collected.
const reg = new FinalizationRegistry((key) => cache.delete(key));
reg.register(obj, "obj-key");
Caveat (and the interview point): finalizers are not guaranteed to run, can run late, and run at unpredictable times. Never rely on them for correctness (closing files, releasing locks) — use explicit cleanup / using (Symbol.dispose) for that. MDN explicitly warns against WeakRef/FinalizationRegistry unless you really need them.
Q: How do you find a leak in DevTools?
A:
- Performance monitor / Memory timeline — does the JS heap grow and never come back down across repeated actions (e.g., open/close a modal 20×)?
- Heap snapshots — take one, perform the action repeatedly, take another, use Comparison view to see what’s being retained and the retainer chain (what’s keeping it alive).
- Look for detached DOM nodes (filter “Detached”) and growing arrays/maps. The retainer path is the answer — it shows the reference chain back to a root.
Gotchas / edge cases
null-ing a variable doesn’t force collection — it just removes one reference; GC runs on its own schedule. There’s no reliablegc()in production.- Closures keep the whole scope, not just the variables you use (engines optimize some away, but don’t count on it) — copy out the one field you need.
- Strings/arrays from
slice/substringmay share backing buffers in some engines, retaining the larger source — rarely an issue, occasionally surprising. console.log(obj)can retainobjfor the lifetime of the console — devtools holding references skews snapshots; test in a clean session.- Maps vs WeakMaps for caches: a plain
Mapcache leaks by design; bound it (LRU) or useWeakMapwhen keyed by object.
What a senior is expected to say
- “GC is reachability-based and generational; cycles are fine. I don’t free memory, I make things unreachable.”
- “Leaks: forgotten timers/listeners, detached DOM held in JS, unbounded caches, over-capturing closures. In React, missing
useEffectcleanup.” - “
WeakMap/WeakSetassociate data without retaining the key;WeakRef/FinalizationRegistryexist but finalizers aren’t guaranteed — never rely on them.” - “I diagnose with heap-snapshot comparison and the retainer chain in DevTools.”
Cross-references
- Closures and memory retention: closures.md
- React effect cleanup (the usual culprit): ../05_react/use_effect_deep.md
- Browser internals / DevTools profiling: ../18_browser_internals/, ../15_performance/
- Backend contrast — Python refcounting + GC: ../../backend/02_python_core/29_weakref.md
Further reading
- MDN — Memory management: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Memory_management
- MDN —
WeakRef(with cautions): https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/WeakRef - V8 — Trash talk: the Orinoco garbage collector: https://v8.dev/blog/trash-talk