Does JavaScript use reference counting for garbage collection?

No. Modern JavaScript engines (V8, SpiderMonkey, JavaScriptCore) do not use reference counting for garbage collection. They rely on tracing algorithms, primarily mark-and-sweep combined with generational and incremental collection. While reference counting was explored in early ECMAScript drafts, it was deprecated due to its inability to resolve circular references and its prohibitive runtime overhead on every reference assignment. For engineers diagnosing memory pressure, understanding this architectural shift is foundational; see Reference Counting vs Tracing GC Algorithms for a comparative breakdown. This guide outlines how to verify GC behavior, isolate circular leaks, and apply targeted V8 flags for rapid triage.

Why Reference Counting Was Abandoned in JS

Reference counting tracks how many pointers reference an object. When the count hits zero, memory is reclaimed immediately. In JavaScript, this approach fails catastrophically with circular references (e.g., DOM nodes referencing each other, or bidirectional object graphs). The runtime would never reach zero, causing silent memory bloat. Additionally, incrementing/decrementing counters on every assignment introduces measurable latency spikes, degrading main-thread responsiveness. Modern engines shifted to tracing collectors that periodically pause the application to identify live objects from root sets (global scope, active call stacks, closures). For a comprehensive overview of runtime memory architecture, consult JavaScript Memory Fundamentals & Runtime Mechanics.

How V8 Tracing GC Actually Works

V8 implements a generational, incremental, and concurrent mark-and-sweep collector. The heap is split into Young Generation (Orinoco) and Old Generation. Short-lived objects are collected frequently via Scavenge (a copying collector). Long-lived objects survive to the Old Generation, where Mark-Compact and Mark-Sweep run concurrently with JS execution using write barriers and idle-time scheduling. This design eliminates the O(N) overhead of reference counting and safely handles cycles. Engineers can observe this via performance.memory (Chrome only) or --trace-gc in Node.js.

Symptom-to-Fix: Isolating Circular Reference Leaks

When profiling a suspected leak, avoid assuming reference counting behavior. Instead, follow this diagnostic flow:

Symptom Diagnosis Command Fix
old_gen heap grows monotonically across requests Take two heap snapshots 60s apart under identical load Filter by Detached or Closure retainers. Trace shortest path to GC root.
GC pauses spike >150ms during idle periods Run node --trace-gc --trace-gc-ignore-scavenger app.js Identify unbounded caches or setInterval retaining lexical scope.
Memory fails to drop after delete obj or obj = null Force GC via global.gc() (debug only) Explicitly nullify references or use AbortController for cleanup.

Expected Memory Delta: After applying the fix and forcing GC, old_gen should drop by 40–150 MB (depending on payload size), and GC pause times should normalize to <10 ms.

Production-Ready Profiling Workflows

Step 1: Enable GC Tracing in Node.js

node --trace-gc --trace-gc-ignore-scavenger app.js

Expected Output: Logs showing [Scavenge] and [Mark-Compact] phases with heap size deltas. Look for monotonically increasing old_gen size after forced GC. A healthy run shows old_gen stabilizing within ±5% of baseline after 3 consecutive GC cycles.

Step 2: Capture Comparative Heap Snapshots in Chrome DevTools

Command: Memory > Heap Snapshot > Take Snapshot A > Execute workload > Force GC (trash icon) > Take Snapshot B Expected Output: Switch to Comparison view. Filter by Delta > 0. Expand (closure) or (system) retainers to identify leaked references. Successful isolation shows # of objects delta dropping to 0 after nullifying the root path.

Step 3: Verify Cycle Resolution with WeakRef

const ref = new WeakRef(obj); 
setTimeout(() => console.log(ref.deref()), 5000);

Expected Output: If GC runs and clears the object, deref() returns undefined. Confirms engine uses tracing, not reference counting. Heap retention should drop by the exact size of obj (e.g., -2.4 MB).

Code Patterns & Memory Impact

Circular Reference Leak Pattern

function createLeak() {
  const a = { id: 'nodeA' };
  const b = { id: 'nodeB' };
  a.ref = b;
  b.ref = a; // Cycle created
  return a;
}
// In a reference-counted engine, this would never be freed.
// In V8, mark-and-sweep will reclaim both once unreachable.

Debugging Note: V8’s tracing GC handles this natively, but developers often mistakenly retain these cycles via global arrays or event listeners. Use --trace-gc to confirm the cycle is unreachable before assuming a leak.

Verifiable Cleanup Pattern

class ResourcePool {
  #active = new Set();
  add(res) { this.#active.add(res); }
  dispose() {
    for (const res of this.#active) {
      res.cleanup?.();
    }
    this.#active.clear(); // Explicitly breaks retainers
  }
}

Memory Delta: Calling dispose() followed by a GC cycle typically yields a 30–80 MB heap reduction. Explicitly nullifying or clearing collections ensures the tracing collector’s root scan finds zero references. Avoid relying on implicit GC timing.

Common Debugging Mistakes

  1. Assuming delete obj.prop triggers immediate memory reclamation: It only removes the key; GC timing is non-deterministic. Always verify with a heap snapshot comparison.
  2. Overusing WeakMap/WeakRef as a substitute for proper lifecycle management: They only prevent leaks, they don’t guarantee cleanup timing. Pair them with explicit teardown hooks.
  3. Ignoring detached DOM subtrees retained by JavaScript closures or third-party library caches: Use the Detached DOM tree filter in DevTools. Expect 0 retained size after proper event listener removal.
  4. Forcing global.gc() in production Node.js: Disables V8’s optimized concurrent GC, causing severe latency spikes (often 200–500ms). Use only with --expose-gc in staging/debug environments.

FAQ

Does JavaScript use reference counting for garbage collection? No. Modern JS engines abandoned reference counting in favor of tracing garbage collectors (mark-and-sweep, generational, incremental). Reference counting cannot resolve circular references and imposes unacceptable runtime overhead.

How can I verify which GC algorithm my runtime uses? Run Node.js with --trace-gc or check V8’s --expose-gc flag. The logs will explicitly show Scavenge, Mark-Compact, and Incremental Marking phases, confirming a tracing collector. Reference counting engines log reference increments/decrements, which V8 does not.

Why does my heap size not drop immediately after deleting an object? Tracing GCs run on a schedule optimized for throughput and latency. Memory is reclaimed during the next mark-and-sweep or scavenger cycle. Use global.gc() (with --expose-gc) only for debugging, never in production.

Can circular references cause memory leaks in modern JavaScript? Only if the cycle remains reachable from a GC root (e.g., global variable, active closure, or DOM event listener). The GC itself handles isolated cycles correctly; leaks occur when application code inadvertently maintains a root path to the cycle.