InterviewsVector
advancedCommon6 min read · Updated Aug 23, 2026

Garbage Collection and Memory Leaks in JavaScript

How JavaScript garbage collection actually works — reachability, mark-and-sweep, generational GC — the four classic memory leaks, and how WeakMap, WeakRef, and heap snapshots let you fix them.


The problem

JavaScript has automatic garbage collection, so you can't leak memory. True or false — and if false, show me a leak.

It's false, and the interviewer knows it. Automatic GC frees memory that's unreachable; a leak in JS is memory you're still reaching by accident — a reference you forgot to drop. The skill isn't managing memory by hand, it's recognizing the reference graphs that quietly grow forever.

The one-liner: GC collects the unreachable; a leak is unwanted reachability.

Where you see it in production

  • A single-page app that gets slower and eventually crashes the tab the longer it's open — usually detached DOM or listeners piling up on route changes.
  • An "infinite" list or cache that never evicts (the exact failure LRU Cache exists to prevent).
  • A React component that sets an interval in an effect and never clears it, so every remount adds another live timer holding its closure.

How GC actually works

Reachability, not reference counting. The collector starts from roots (the global object, the current call stack, active closures) and marks everything reachable from them. Anything unmarked is garbage and gets swept. This is why cycles are not leaks: two objects that point at each other but are unreachable from any root are both collected. (Naive reference counting can't do that — it's why the DOM/JS boundary leaked in old IE.)

Generational, in V8. Most objects die young, so the heap is split:

  • Young generation (nursery) — new allocations. Collected often and cheaply with a fast copying "scavenge." Survivors get promoted.
  • Old generation — long-lived objects. Collected less often with mark-sweep-compact, incrementally and concurrently so it doesn't freeze the main thread for long.

You can't force it. There's no portable gc() in production code — you influence collection by dropping references so objects become unreachable. delete obj.key, arr.length = 0, or reassigning a variable to null all sever edges in the graph.

The four classic leaks

1. Accidental globals

function process() {
  leaked = new Array(1_000_000); // no var/let/const → global, never collected
}

An unqualified assignment (outside strict mode) creates a property on the global object — a permanent root. "use strict" turns this into a ReferenceError, which is why it's on by default in modules.

2. Forgotten timers and listeners

function startPolling(node) {
  const bigState = buildHugeState();
  setInterval(() => {
    node.textContent = read(bigState); // closure keeps node AND bigState alive forever
  }, 1000);
}

The interval callback is reachable from the timer system (a root) for as long as the interval lives, and it closes over node and bigState. Nothing here is ever collected. Fix: keep the timer id and clearInterval it; removeEventListener on teardown. In React, that's the effect cleanup return.

3. Detached DOM nodes

const cache = {};
function cacheRow(id) {
  cache[id] = document.getElementById(id); // JS reference to the node
}
// later:
container.innerHTML = ""; // removed from the document...
// ...but cache[id] still points at it → detached, alive, leaked

Removing a node from the document doesn't free it if JavaScript still holds a reference. The whole subtree stays in memory. This is the single most common SPA leak, and heap snapshots label these nodes "Detached."

4. Unbounded caches and closures capturing too much

A Map used as a cache that only ever sets is a leak with a friendly name. So is a closure that captures a large object it doesn't need. memoize and API-call caching both flag this: without eviction, the cache is the leak. The fix is bounded eviction (LRU) or weak keys.

Predict the outputWhich snippet leaks memory as it runs repeatedly?
// A
el.addEventListener("click", handler);
el.remove();

// B
const wm = new WeakMap();
wm.set(el, meta);
el.remove();

The weak toolbox: WeakMap, WeakSet, WeakRef

WeakMap / WeakSet hold their keys weakly: an entry doesn't count as a reference for reachability, so keying metadata by a DOM node or object lets both disappear the moment the rest of your code stops using them. That's why they're the right structure for "extra data attached to an object I don't own the lifecycle of." They're not enumerable (you can't iterate them) precisely because entries can vanish at any GC.

const metadata = new WeakMap();
function tag(node, info) {
  metadata.set(node, info); // when node is GC'd, this entry goes with it
}

WeakRef + FinalizationRegistry are the escape hatch for caches that want to observe collection — hold a value weakly and get a callback when it's reclaimed. Treat them as a last resort: the spec makes no guarantees about when (or whether) finalizers run, so never put required cleanup in a FinalizationRegistry. Interviewers like hearing that caveat more than the API.

Measuring memory

You find leaks the same way you find slow code — by measuring, not guessing:

  • Heap snapshot (DevTools → Memory). Take one, do the suspect action a few times, take another, and use Comparison view to see what grew. Filter by "Detached" to find zombie DOM.
  • Allocation timeline — records allocations over time; tall bars that never come back down are retained memory.
  • The three-snapshot test — snapshot, run the action N times returning to the same state, snapshot. Objects whose count grows with N and never drops are your leak. Retainers show the reference chain keeping them alive.
  • performance.measureUserAgentSpecificMemory() — an async, cross-origin-isolated API for total tab memory in the field. (The old performance.memory is non-standard and coarse.)

Interview follow-ups to be ready for

  • "Are reference cycles a leak in JS?" No — modern GC is reachability-based, so an unreachable cycle is collected. (They were a leak across the old IE DOM/JS boundary, which mixed refcounting and tracing.)
  • "Why can't you force GC?" It's non-deterministic by design and the engine schedules it to minimize pause time; exposing it would let pages jank themselves and enable timing attacks. --expose-gc exists only for debugging.
  • "WeakMap vs Map for a cache?" WeakMap auto-evicts when keys die but requires object keys and can't be sized or iterated; Map needs explicit eviction (LRU/TTL) but works with any key and is measurable. Pick by whether the key's lifetime should drive eviction.
  • "How would you prove a component leaks?" Mount/unmount it N times, take before/after heap snapshots, and show detached nodes or listener counts growing with N.

  • Why interleaving DOM reads and writes forces synchronous layout, the read/write batching fix, requestAnimationFrame, and compositor-only properties.

  • Big-O reasoning the way interviewers want it: the Map/Set/object/array cheat sheet, hidden O(n) costs like spread and includes, and trading memory for speed.

  • A hands-on exercise: profile first, fix the algorithm, hoist invariant work, cut allocations, memoize, then chunk off the main thread — in that order.

  • performance.now vs Date.now, the User Timing API, PerformanceObserver, DevTools flame charts, and the micro-benchmark traps that make numbers lie.