What triggers garbage collection in JavaScript?
V8 runs a generational, incremental, mostly-concurrent mark-and-sweep collector. GC is triggered by heap-size thresholds — minor GC (young generation, Scavenge) fires often and is fast; major GC (Mark-Compact, full heap) fires on memory pressure or idle. You can't manually trigger it from JS, only influence it by dropping references and avoiding accidental retainers (closures, listeners, detached DOM, globals).
GC in V8 (Chrome / Node) is generational mark-and-sweep with incremental and concurrent steps. JS doesn't expose a "free()" — the runtime decides when to collect, and what.
The generational model. Most objects die young. V8 splits the heap into:
- Young generation ("new space", ~1–8MB, small). Allocations happen here. Collected by the Scavenger — a copying collector that runs every few seconds under typical load. Survivors of two Scavenges get promoted.
- Old generation (large, MB to GB). Long-lived objects. Collected by Mark-Compact — slower, runs less often, can pause longer (mitigated by being mostly concurrent + incremental).
What triggers it.
- Allocation pressure. When young space fills, a minor GC fires.
- Heap-size thresholds. Old generation has soft limits; crossing them schedules a major GC.
- Idle time hints. V8 prefers to run major GC during browser idle slots (between rAFs) to avoid jank.
- Explicit pressure in Node —
--expose-gclets you callglobal.gc()(only for tests/benchmarks; not available to web pages). - OOM emergency. Under memory pressure, V8 will run an aggressive full GC.
JS apps can't trigger GC. They can only stop retaining things.
Reachability rules: an object is live if reachable from a GC root. Roots are: the global object, the current call stack, currently-executing closures, and platform-managed handles (timer callbacks, event listeners, microtask queue).
Common retainers (the actual interview content).
- Closures holding large scopes.
``js function leaky() { const big = new Array(1e6); return () => 1; // returns a function that closes over big even if unused } ` V8 is good at trimming captured variables an inner function doesn't reference — but eval or with in scope inhibits the optimization, and some closures (e.g., debug` accessing all variables) defeat it. Capture only what you need.
- Event listeners + DOM references. Attaching a listener with
{ once: false }and never removing it keeps both the handler and the DOM node alive. Especially bad: detached DOM trees referenced by a JS variable — they don't appear on screen but they're not collected.
- Timers.
setInterval/setTimeoutcallbacks are GC roots until they fire (or areclearIntervald).
- Maps holding strong references. A
Map<Element, Metadata>keeps the element alive even after it's removed from the DOM. UseWeakMapwhen the key is an object whose lifetime you don't own.
- Global / module-scope caches.
const cache = new Map()at module level lives forever.
- Promise chains. A pending
new Promise(resolve => ...)keepsresolvealive — if you never call it, you've leaked the closure.
WeakRef and FinalizationRegistry (2021+). WeakRef lets you hold a reference that doesn't prevent collection; FinalizationRegistry notifies you when an object was collected. Use for caches that should drain under pressure. Don't depend on finalizers running — they're best-effort, not guaranteed.
Detecting leaks.
- Performance → Memory tab in Chrome. "Heap snapshot" twice; compare retainer trees in the second one.
- Allocation timeline shows what code is allocating most.
- Node
--inspect+ Chrome DevTools for server-side heap snapshots. performance.memory.usedJSHeapSize— coarse-grained metric, fine for trend monitoring.
Why GC pauses cause jank. Before incremental GC, a major collection paused the main thread for 50–500ms = dropped frames. Modern V8 splits the work into small slices, interleaved with JS execution, and runs concurrent threads for marking. INP (Interaction to Next Paint) is partly a GC visibility metric — long INP often correlates with long GC pauses.
Senior framing. The right mental model: GC is reactive, not predictable. The only control you have is what you retain. Watch for closures, timers, listeners, Maps. Make caches WeakMap/WeakRef where lifetimes are externally owned. Profile with heap snapshots, don't speculate.
Follow-up questions
- •Difference between WeakMap and Map for caching DOM-keyed metadata?
- •How does V8 mitigate long GC pauses for animations?
- •What's a common closure pattern that defeats variable trimming?
- •Why isn't `delete obj.prop` enough to free memory?
Common mistakes
- •Assuming `obj = null` is needed in normal code — it usually isn't.
- •Holding DOM nodes in a global Map without using WeakMap.
- •Forgetting `clearInterval` on long-lived intervals.
- •Caching by stringified IDs forever in a module-scope Map.
Performance considerations
- •Pre-allocate arrays of known size; avoid `.push` growth in hot loops if you can.
- •Object pools are an anti-pattern in JS in 2026 — V8's young GC is faster than pool bookkeeping for most cases.
- •Avoid `for...in` over hot objects — produces hidden class transitions and extra allocations.
Edge cases
- •Web Workers have their own heap — leaks there don't show up in the main heap snapshot.
- •Service Workers persist across page loads; their leaks survive reloads.
- •Detached canvases / images can retain large GPU memory not visible in JS heap.
Real-world examples
- •SPA navigation leaving listeners attached to unmounted page elements.
- •React closures inside `useEffect` capturing stale large objects.
- •Chart libraries that don't tear down WebGL contexts on unmount.