JavaScriptGarbage Collection

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?

JS
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 GC
Mark 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:

  1. Mark. Start at the roots. Visit every object reachable from them and mark it as live.

  2. Sweep. Walk the heap. Anything not marked is garbage — release its memory back to the allocator.

  3. 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

JS
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 other
Weak 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 or undefined if 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

JS
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.
Treat WeakRef as a last resort
Most code does not need `WeakRef` or `FinalizationRegistry`. The timing of collection is unobservable on purpose — relying on it to "free things at the right moment" is fragile. Reach for them only when you genuinely need to mirror an external resource lifetime.
What you can and cannot influence

You cannot:

  • Force a collection from JavaScript. There is no portable API for it. (Flags like Node --expose-gc exist 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 — TypedArray views 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/WeakSet for caches keyed by objects you do not own.

A worked example

lifetime walk-through

JS
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());
  1. At [A] a new array of objects is allocated in the young generation.

  2. At [B] another array is allocated; the array from [A] becomes unreachable as soon as .filter finishes — its memory will be reclaimed by the next young-gen pass at almost zero cost.

  3. At [C] only a number is returned. The filtered array becomes unreachable on the next line and is freed shortly after.

  4. The original rows argument is unreachable once process returns, unless the caller still holds it.

The take-away
Allocating short-lived objects is *cheap*. Holding on to long-lived ones longer than necessary is *expensive*. Optimise for lifetime, not raw allocation count.