Skip to content
JavaScript js scope-closures 5 min read

Garbage Collection

JavaScript manages memory for you: you allocate by creating values, and the engine automatically frees memory that is no longer in use. The mechanism behind this is the garbage collector (GC), and the single concept it relies on is reachability. Understanding how the GC decides what to keep — and what keeps your objects alive longer than intended — is the difference between code that runs lean and code that slowly leaks memory until the tab or process falls over.

Reachability: the core idea

A value is reachable if it can still be accessed somehow from the running program. The engine starts from a set of inherently reachable values called the roots — the global object (window in browsers, globalThis in Node), the currently executing function’s local variables and parameters, and the chain of functions that called it. Any value reachable from a root, by following references, is also reachable. Everything else is garbage and may be reclaimed.

let user = { name: "Ada" };
// The object { name: "Ada" } is reachable via `user`.

user = null;
// Nothing references the object anymore — it becomes garbage
// and will be collected at some point.

The key word is some point. You never call the collector directly; the engine runs it on its own schedule. You only control which references exist.

Mark-and-sweep

Modern engines (V8 in Chrome and Node, SpiderMonkey in Firefox, JavaScriptCore in Safari) use a mark-and-sweep algorithm. It works in two phases:

  1. Mark — Starting from the roots, the GC walks every reference and marks each reachable object as “in use.”
  2. Sweep — It scans the heap and frees any object that was not marked.

Because marking follows references transitively, even complex object graphs and cycles are handled correctly. Two objects that reference each other but are unreachable from any root are still collected — they simply never get marked.

function makePair() {
  const a = {};
  const b = {};
  a.partner = b; // cycle: a -> b
  b.partner = a; // cycle: b -> a
  return null;   // neither a nor b is returned or stored
}

makePair();
// a and b reference each other, but nothing outside reaches them.
// Mark-and-sweep collects both; reference counting could not.

Reachability — not reference counting — is what JavaScript uses. This is why circular references do not leak in JS, unlike in some older systems.

V8 further optimizes this with a generational collector: new objects live in a small “young generation” that is collected often and quickly, while objects that survive get promoted to an “old generation” collected less frequently. This matches the observation that most objects die young.

Common sources of memory leaks

A “leak” in a GC language doesn’t mean memory vanishes — it means you are unintentionally keeping objects reachable so the GC can never free them. Here are the usual culprits.

Lingering references

Forgotten entries in long-lived objects, arrays, or caches keep their contents alive forever.

const cache = {};

function process(id, data) {
  cache[id] = data; // never deleted — grows without bound
  return data.length;
}

If nothing ever removes old keys, cache pins every data value for the lifetime of the program. A Map with a deliberate eviction policy — or a WeakMap — is the fix.

Forgotten timers and listeners

Active timers and event listeners are roots in their own right: the callback (and everything it closes over) stays reachable until you stop them.

const widget = { huge: new Array(1_000_000).fill("x") };

const id = setInterval(() => {
  console.log(widget.huge.length);
}, 1000);

// Forgetting this line leaks `widget` forever:
clearInterval(id);

The same applies to addEventListener. Always pair it with removeEventListener, or pass an AbortSignal to detach in one shot:

const controller = new AbortController();
button.addEventListener("click", onClick, { signal: controller.signal });

// Later, when the component is destroyed:
controller.abort(); // removes the listener and frees its captures

Closures that capture too much

A closure keeps its entire enclosing scope alive. A long-lived closure that only needs one small field but closes over a large object pins the whole thing.

function attach(element) {
  const bigData = new Array(1_000_000).fill(0);
  const size = bigData.length;        // extract what you need
  element.onclick = () => console.log(size); // closes over `size`, not bigData
}

Detached DOM nodes

If you remove a node from the document but still hold a JavaScript reference to it, the node — and its whole subtree — cannot be collected.

let detached = document.getElementById("panel");
detached.remove();      // gone from the page...
// ...but still referenced here, so it stays in memory.
detached = null;        // now it can be collected

WeakMap, WeakSet, and weak references

When you want to associate data with an object without preventing that object from being collected, use WeakMap or WeakSet. Their keys are held weakly: if the only remaining reference to a key is inside the weak collection, the entry disappears automatically.

StructureKeys heldIterableTypical use
MapStronglyYesGeneral key/value storage
WeakMapWeaklyNoMetadata keyed by object, auto-cleaned
WeakSetWeaklyNo”Have I seen this object?” tracking
const metadata = new WeakMap();

function tag(obj) {
  metadata.set(obj, { taggedAt: Date.now() });
}

let node = { id: 1 };
tag(node);
node = null; // the WeakMap entry is now eligible for GC automatically

Best practices

  • Null out or delete references you no longer need, especially in long-lived objects and module-level caches.
  • Always clear timers (clearInterval/clearTimeout) and remove listeners — an AbortController makes teardown a single call.
  • Keep closures lean: capture the specific value you need, not a large enclosing object.
  • Drop references to removed DOM nodes so detached subtrees can be reclaimed.
  • Use WeakMap/WeakSet for object-keyed metadata you want collected alongside the object.
  • Give caches a bounded size or an eviction strategy instead of letting them grow forever.
  • Profile real usage with the browser DevTools Memory panel (heap snapshots) or Node’s --inspect rather than guessing.
Last updated June 1, 2026
Was this helpful?