Garbage collector (GC)
The garbage collector (GC) is the part of the runtime (V8 in Chrome and Node.js, SpiderMonkey in Firefox and so on) that automatically frees the memory occupied by objects that can no longer be reached from the program's roots. The main criterion is reachability: if an object is reachable from the roots (global objects, the current call stack, registers, live closures, the DOM tree and so on) it is alive; if it is unreachable, its memory can be freed.
Theory
TL;DR
- The GC removes unreachable objects, not "unneeded" ones: the only criterion is reachability from the roots.
- The base algorithm is mark-and-sweep, often with compaction to fight fragmentation.
- Modern implementations are generational (young and old), incremental and concurrent, to reduce pauses.
- Reference counting is not used in JS: it breaks on cycles such as
A -> B -> A. WeakMap/WeakSetdo not prevent collection,FinalizationRegistrygives you a callback after collection but with no timing guarantees.- For a developer the key skill is managing references: listeners, timers, caches, closures, DOM nodes.
Quick example
let user = { name: 'Alice' }; // the object is reachable from a root through user
let admin = user; // a second reference to the same object
user = null; // the first reference is gone, but the object is alive:
// admin still holds it, so the GC will not free it
admin = null; // now the object is unreachable and may be collected
// (exactly when is up to the runtime, not your code)How the GC decides what to delete
Reachability
The roots are what the runtime can always access: the global object, variables on the current call stack, registers, live closures, the DOM tree. Everything that can be reached from the roots through a chain of references counts as alive. Everything else is garbage.
Mark-and-sweep
- Mark: walk the object graph from the roots and mark everything reachable.
- Sweep: walk the heap and free everything unmarked.
On top of that the runtime may perform compaction: moving the survivors together to remove memory fragmentation.
Why not reference counting
Reference counters break on cycles (A -> B -> A): two objects hold each other, the counters never drop to zero, and you get a leak. That is why JS uses tracing collectors (mark-and-sweep or mark-compact), for which a cycle is not a problem: if the whole component is unreachable, it gets collected.
Generational, incremental and concurrent GC
Generational GC
The observation is that "most objects die young". Hence:
- Young generation (nursery, new space): new objects land here, collections are frequent and fast (minor GC, scavenging).
- Old generation (tenured, old space): objects that survived several minor collections are promoted here, collections are rarer but more expensive (major GC).
Technically this is implemented with:
- bump-pointer allocation (very fast sequential allocation),
- copying GC for the young generation: two semispaces, live objects are copied from the from-space into the to-space,
- mark-sweep or mark-compact for the old generation.
Write barrier and remembered set
So that the young and old generations can reference each other correctly, the runtime uses a write barrier together with card marking / a remembered set: a cheap metadata write whenever an "old to young" reference is created, so that the minor GC knows which old objects may be holding young ones.
Tri-color marking and short pauses
A full stop-the-world collection with long pauses is unacceptable both in a UI and on a server, so runtimes use:
- Tri-color marking (white, grey, black): a formal marking model that allows the graph walk to advance incrementally.
- Incremental GC: marking is split into small chunks, and between them the application keeps running.
- Concurrent GC: part of the work (marking, for example) runs in parallel with the application on another thread.
- Parallel marking and sweeping, plus compaction with pause minimisation.
The idea is simple: reduce jank and freezes by trading them for small, predictable pauses.
How it works in V8
Heap spaces
The heap is split into spaces: new space (the semispaces of the copying GC), old space (mark-sweep or mark-compact), code space, map space, and large object space (where objects are not moved).
Minor GC and major GC
- Minor GC (Scavenger): quickly copies live objects from the from-space into the to-space, and those that survived N collections get promoted into the old space.
- Major GC: precise marking, then sweep or compact, incremental and concurrent in order to shorten pauses.
- The details: write barriers, remembered sets, parallel marking and sweeping, heuristics around memory pressure and fragmentation.
When the GC runs
- There is no free room for a new allocation (allocation failure).
- Memory pressure from the OS or the host.
- Heuristics based on heap growth and the frequency of minor collections.
- In DevTools you can trigger a collection by hand, but only for debugging.
Weak references and finalization
- WeakMap and WeakSet: the keys (for
WeakMap) and the elements (inWeakSet) do not prevent collection. As soon as the object is unreachable everywhere else, the GC may free it and the entry disappears "by itself". This is useful for caches and memoization without leaks. - FinalizationRegistry: lets you run a callback after an object has been collected. Important warnings: the timing is non deterministic, the order is not guaranteed, and the call may never happen at all (for example when the process shuts down). You must not rely on it for logic, only for "soft" side effects such as clearing an external cache.
An example of a safe cache:
const cache = new WeakMap(); // the key is an object, the value is the computation
function heavy(obj) {
if (cache.has(obj)) return cache.get(obj);
const val = compute(obj);
cache.set(obj, val);
return val;
}Memory leaks: practice and diagnostics
Typical sources of leaks
- Long lived references: caches, singletons, closures, global structures, stored DOM nodes.
- Events and timers: a handler or a
setIntervalthat was never removed, danglingPromisereferences. - DOM and JS cycles: you keep a reference to a node removed from the DOM, or to its closure.
- Unbounded structures: a
Mapor an array that grows and is never cleared.
What to do
- Remove event handlers and timers:
const handler = () => {};
el.addEventListener('click', handler);
// ...
el.removeEventListener('click', handler);- For caches keyed by objects use
WeakMaporWeakSet. - Avoid unnecessary global and module level references.
- Be careful with closures: do not capture large objects you do not need.
- Bound the size of caches (LRU, for instance) and clear them.
- In React, watch the cleanup function in
useEffect.
Diagnostic tools
- Chrome DevTools, the Performance and Memory panels: heap snapshots, allocation instrumentation, looking for "Detached HTML nodes" and growing retainers.
performance.memory(browser, experimental), rough telemetry.- Node.js:
--inspect, heap snapshots,clinicandheapdump, profilers, the--max-old-space-sizeflag. - Test idea: run the scenario N times, and memory should stabilise. Constant growth is a leak signal.
Limits worth remembering
- The GC is non deterministic: you do not control the exact moment of collection.
- Finalizers are not guaranteed to run before the process exits or the tab closes.
- Long pauses are still possible, although modern collectors try hard to keep them short.
Common mistakes
- Thinking the GC removes "unneeded" objects. There is only one criterion: unreachability. An object with even one live reference from a root will not be collected, however useless it is to you.
- Believing that a reference cycle causes a leak. That is a reference counting problem, not a tracing GC problem: an unreachable cycle is collected without trouble.
- Relying on
FinalizationRegistryto release resources. The callback may never run, so close files, sockets and subscriptions explicitly. - Treating
delete obj.proporx = nullas a command to the collector. It only drops one reference, and collection happens when the runtime decides. - Not removing listeners and
setIntervalwhen a component is destroyed. A handler holds both the DOM node and its whole closure, which is the most common leak in an SPA. - Using a plain
Mapas an object keyed cache. It holds keys strongly, so the objects live as long as the cache does, and that scenario needs aWeakMap. - Chasing micro optimisations instead of measuring. Take a heap snapshot and compare retainers first, and only then change the code.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.