RoadmapDay 8 / 80
JavaScript CoreMonth 1 · Week 2

Day 8: Garbage Collection Mechanics & Memory Leak Detection

Understand mark-and-sweep and generational GC well enough to reason about real leaks in long-running SPAs, and practice finding one in DevTools.

Mark this day complete

Study

Concepts

Reachability, not reference counting, decides what survives

V8 (and modern engines generally) use a mark-and-sweep algorithm rooted at a set of "roots" (global object, currently executing call stack, closures in scope). The GC marks everything reachable from roots, then sweeps (frees) everything unmarked. An object with zero incoming references is NOT what matters — what matters is whether it is reachable from a root, which is why two objects referencing each other in a cycle can still be correctly collected once nothing else points to either.

V8 splits the heap into a small "young generation" (new objects, collected frequently and cheaply via a fast Scavenge algorithm since most objects die young) and an "old generation" (survivors, collected less often via a more expensive mark-sweep-compact pass). This generational strategy is why allocating many short-lived objects is usually cheap, but is exactly what triggers frequent young-gen GC pauses if overdone in a hot path like a scroll handler.

The classic leak patterns in real apps

Detached DOM nodes: a removed DOM node stays in memory if a JS variable (or a closure) still references it. Forgotten timers/intervals/subscriptions: `setInterval` or an event listener that outlives the component that created it keeps its whole closure scope alive. Global variable accumulation: attaching growing arrays/caches to `window` or a module-level singleton with no eviction policy.

In React specifically: subscribing in `useEffect` without a cleanup function, or capturing stale state in a closure passed to a long-lived listener, are the two most common leak sources.

See It

Visualizations

Visualization

Mark-and-Sweep in one GC cycle

Pause JS execution

(a "stop-the-world" pause, minimized via incremental/concurrent marking)

Mark

walk from roots (globals, stack, closures) and flag every reachable object

Sweep

reclaim memory for every unmarked object

Compact (old-gen only)

defragment memory to keep allocation fast

Build It

Code Examples

A real leak: forgotten interval closure

js
function startPolling(bigDataset) {
  // bigDataset stays alive for as long as this interval exists,
  // even if the component that called this unmounts.
  const id = setInterval(() => {
    console.log('still polling with', bigDataset.length, 'rows');
  }, 5000);
  return id;
}

const id = startPolling(new Array(1_000_000).fill('row'));
// Leak: nothing ever calls clearInterval(id) — bigDataset lives forever.

// Fix: always pair the subscription with teardown
function startPollingFixed(bigDataset) {
  const id = setInterval(() => {
    console.log('polling', bigDataset.length);
  }, 5000);
  return () => clearInterval(id); // caller MUST invoke this on cleanup
}

React version of the same leak, and the fix

jsx
// Leaks: no cleanup, listener outlives the component
useEffect(() => {
  const onResize = () => setWidth(window.innerWidth);
  window.addEventListener('resize', onResize);
}, []);

// Fixed: cleanup function removes the listener on unmount
useEffect(() => {
  const onResize = () => setWidth(window.innerWidth);
  window.addEventListener('resize', onResize);
  return () => window.removeEventListener('resize', onResize);
}, []);

Remember

Key Takeaways

  • GC frees what is unreachable from roots — reference cycles are collected fine, unlike naive refcounting.
  • Young-gen (Scavenge) collections are frequent and cheap; old-gen (mark-sweep-compact) are rarer and pricier.
  • The top 3 real-world leaks: detached DOM refs, forgotten timers/listeners, and unbounded global caches.
  • In React, every subscription started in useEffect needs a matching cleanup return.
  • Use Chrome DevTools Memory tab (heap snapshot diff, or Allocation instrumentation) to find leaks empirically, not by guessing.

Do It

Practice

  1. 1Take two heap snapshots in DevTools (before/after interacting with a page repeatedly) and compare retained size to find a growing object.
  2. 2Intentionally write a component that leaks via a missing useEffect cleanup, confirm the leak in the Memory tab, then fix it.
  3. 3Explain, in your own words, why a detached DOM node still counts as "reachable" if a JS variable references it.