Garbage Collection Mental Model
Reachability, mark-and-sweep, retained closures and DOM — a practical GC model for frontend engineers debugging memory.
- 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/
windowproperties - 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:
- Take a heap snapshot
- Perform the action you suspect
- Force GC (devtools button)
- 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.”
Related
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
- Debounce ImplementationImplement debounce in JavaScript — trailing vs leading, cancel/flush, TypeScript typing, and when to throttle instead.
- DocumentFragmentBuild subtrees off-DOM with DocumentFragment — one insert, fewer reflows, and how it differs from a wrapper div.
- IntersectionObserverObserve element visibility asynchronously — lazy images, infinite scroll, ad viewability, without scroll listener jank.
- Throttle ImplementationImplement throttle in JavaScript — leading vs trailing edges, cancel, comparison with debounce, React cleanup, and interview-ready code.
- AbortControllerCancel fetch and other async work with AbortController and AbortSignal — timeouts, race conditions, and cleanup when components unmount.