Garbage Collection
JavaScript objects do not have a free function. The runtime quietly notices when an object can no longer be reached from your code and reclaims its memory in the background. That is garbage collection (GC). The details vary between engines, but the mental model is the same everywhere: reachability decides what stays and what goes.
The reachability rule
An object is reachable if either:
It is one of the roots — the global object, anything on the current call stack, intrinsics the engine itself holds, registered listeners on live DOM nodes.
It is referenced by another reachable object.
Anything else is unreachable and may be collected. The engine has full permission to free it; the language gives you no way to observe the moment it happens.
when does this become collectable?
let user = { name: "Ada" };
let admin = user; // 2 references to the same object
user = null; // still reachable via admin
admin = null; // now unreachable -> eligible for GCMark and sweep at a sketch level
Every modern JavaScript engine uses some variant of tracing GC, and the textbook tracing algorithm is mark-and-sweep:
Mark. Start at the roots. Visit every object reachable from them and mark it as live.
Sweep. Walk the heap. Anything not marked is garbage — release its memory back to the allocator.
Reset. Clear the marks for the next cycle.
Pure mark-and-sweep would freeze the program while it runs. Real engines work incrementally and concurrently: they split the work into short bursts interleaved with your code, and they run parts on a separate thread, so you rarely see a noticeable pause.
The generational hypothesis
Decades of measurements showed that most objects die young: a string you built for a single function call, a temporary array in a loop, a promise that resolved immediately. A small number of objects, by contrast, live a long time — the application root, long-lived caches, the DOM.
Engines exploit that by splitting the heap into generations:
Young generation (the nursery). New allocations land here. It is small, scanned often and very cheaply: most objects are dead by the next scan and the survivors are copied into the old generation.
Old generation. Objects that survived a few young-gen cycles. Scanned much less often, with a bigger, more thorough algorithm.
The pay-off is huge: short-lived objects cost almost nothing because their memory is reclaimed in the cheap pass. V8 (Chrome, Node, Edge) and SpiderMonkey (Firefox) both work this way.
Cycles are not a problem
Reference-counting GCs struggle with cycles — A points at B and B points at A, both have count 1, neither is freed. Tracing GCs do not care: if nothing outside the cycle can reach it, the whole cycle is unreachable in one pass.
cyclic but collectable
function pair() {
const a = {};
const b = {};
a.other = b;
b.other = a;
return a;
}
let p = pair();
p = null; // both a and b are now unreachable, even though they point at each otherWeak references — a peek
Most references are strong: holding one keeps the target alive. JavaScript also has weak references that do not. They are designed exactly for the "cache that should not extend lifetime" case.
WeakMap— keys must be objects, and an entry disappears when no other code can reach the key.WeakSet— same idea, set of objects.WeakRef— holds a single object;.deref()returns it orundefinedif it has been collected.FinalizationRegistry— runs a callback (eventually) when a target is collected; mostly used by library authors for cleanup of external resources.
WeakMap for per-element data
const tooltipFor = new WeakMap();
function attachTooltip(el, text) {
tooltipFor.set(el, text);
}
// When `el` is removed from the DOM and no JS variable holds it,
// the tooltipFor entry vanishes automatically.What you can and cannot influence
You cannot:
Force a collection from JavaScript. There is no portable API for it. (Flags like Node
--expose-gcexist but only for tests and benchmarks.)Predict when an object will be freed. Only that it will, eventually, once unreachable.
Observe the collection of a specific object in user code (other than via
FinalizationRegistry, and even there only "eventually").
You can make life easier for the collector:
Let variables go out of scope when you are done with them. Tight scopes win.
Null out long-lived references that hold large data once you no longer need them.
Reuse buffers in hot loops instead of allocating fresh ones —
TypedArrayviews and pre-sized arrays both work.Remove event listeners, clear timers, and delete cache entries when the work they belong to ends. (See Memory Management.)
Use
WeakMap/WeakSetfor caches keyed by objects you do not own.
A worked example
lifetime walk-through
function process(rows) {
const summary = rows
.map((r) => ({ id: r.id, total: r.qty * r.price })) // [A]
.filter((r) => r.total > 0); // [B]
return summary.length; // [C]
}
process(loadBigCsv());At [A] a new array of objects is allocated in the young generation.
At [B] another array is allocated; the array from [A] becomes unreachable as soon as
.filterfinishes — its memory will be reclaimed by the next young-gen pass at almost zero cost.At [C] only a number is returned. The filtered array becomes unreachable on the next line and is freed shortly after.
The original
rowsargument is unreachable onceprocessreturns, unless the caller still holds it.