frontend / javascript core / memory-and-gc.md

Memory and Garbage Collection

4 min read source

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/intervalssetInterval whose callback closes over a large object and is never cleared.
  • Dangling event listenersaddEventListener without removeEventListener; 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 Map you only ever set into.
  • 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 or undefined if 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:

  1. Performance monitor / Memory timeline — does the JS heap grow and never come back down across repeated actions (e.g., open/close a modal 20×)?
  2. 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).
  3. 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 reliable gc() 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/substring may share backing buffers in some engines, retaining the larger source — rarely an issue, occasionally surprising.
  • console.log(obj) can retain obj for the lifetime of the console — devtools holding references skews snapshots; test in a clean session.
  • Maps vs WeakMaps for caches: a plain Map cache leaks by design; bound it (LRU) or use WeakMap when 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 useEffect cleanup.”
  • WeakMap/WeakSet associate data without retaining the key; WeakRef/FinalizationRegistry exist but finalizers aren’t guaranteed — never rely on them.”
  • “I diagnose with heap-snapshot comparison and the retainer chain in DevTools.”

Cross-references

Further reading