ESC

Type to search the knowledge base.

Garbage Collection Mental Model

Reachability, mark-and-sweep, retained closures and DOM — a practical GC model for frontend engineers debugging memory.

advanced3 min read
  • javascript
  • garbage-collection
  • memory
  • performance

JavaScript is garbage-collected. You don’t free(). You control reachability: if something can still be found from a root by following references, it stays alive. Memory bugs on the frontend are almost always “I accidentally kept a reference,” not “the GC is broken.”

Roots and reachability

Roots include:

  • Global/window properties
  • The current call stack’s locals
  • Active timers, listeners, and async callbacks still registered
  • DOM nodes still in the document (or referenced from JS)
let root = { name: 'keep' };
const orphan = { name: 'gone' };
root = null;
// orphan was never rooted → collectible immediately after creation if unused

Mark-and-sweep (simplified): mark everything reachable from roots; sweep unmarked. Cycles are fine:

const a = {};
const b = {};
a.ref = b;
b.ref = a;
// if nothing else points to a/b, both can be collected

Older “reference counting only” VMs failed on cycles; modern engines don’t.

What retains memory in SPAs

// 1) global cache
window.__CACHE__ = huge;

// 2) closed-over data in a long-lived listener
function attach(el, hugeData) {
  el.addEventListener('click', () => {
    console.log(hugeData.id); // retains entire hugeData
  });
}

// 3) detached DOM still referenced
const node = document.querySelector('#panel');
node.remove();
// if `node` or a listener on it still reachable, subtree can live on

// 4) timers
const id = setInterval(() => poll(), 1000);
// clearInterval(id) when done

Generational intuition

Engines optimize for many short-lived objects (nursery) and fewer long-lived ones. Allocating huge temporary arrays every frame pressures GC and can cause jank when collections run. Patterns:

  • Reuse buffers in hot paths when profiling shows allocation churn
  • Don’t micro-optimize allocations you never measured

Weak references

WeakMap / WeakSet / WeakRef let you associate data without preventing collection of the key/target:

const meta = new WeakMap();
meta.set(element, { clicks: 0 });
// when element is unreachable, entry can disappear

See WeakMap and WeakSet and FinalizationRegistry for the sharp edges.

Measuring

Browser Performance / Memory panels:

  1. Take a heap snapshot
  2. Perform the action you suspect
  3. Force GC (devtools button)
  4. Snapshot again — compare retained objects

Look for detached HTMLElement counts, growing arrays, listener lists.

Myths

Myth Reality
nulling every variable helps Only if it was the last reference
GC runs every N seconds Engine decides
Circular refs always leak Not in modern JS GC
delete obj.prop frees now Just removes a property; object may still be live

Interview answer

“GC frees objects that aren’t reachable from roots. Cycles are OK with mark-and-sweep. Frontend leaks usually come from globals, listeners, timers, caches, and detached DOM held by JS. I debug with heap snapshots and prefer WeakMap for metadata keyed by objects. I never rely on finalizers for correctness.”

Detached DOM example

const registry = new Map(); // strong refs

function mount(id) {
  const el = document.createElement('div');
  document.body.append(el);
  registry.set(id, el);
}

function unmount(id) {
  const el = registry.get(id);
  el?.remove();
  // leak if you forget:
  // registry.delete(id);
}

Removing from the document is not enough if JS still points at the node. Console retainers in DevTools show why a detached node lives — follow the path back to a module-level Map, a closure, or a listener. Fix the retaining edge; don’t try to “force GC.”

Further reading

Related guides