Does JavaScript Use Reference Counting for Garbage Collection?

No. Modern JavaScript engines (V8, SpiderMonkey, JavaScriptCore) abandoned reference counting years ago and rely exclusively on tracing algorithms. This matters for diagnosing production memory pressure: if you arrived here expecting that delete obj.prop or obj = null immediately frees memory, you are working from the wrong mental model. This page is a child of the Reference Counting vs Tracing GC Algorithms cluster under the JavaScript Memory Fundamentals & Runtime Mechanics pillar.

Symptom-to-Fix Diagnostic Matrix

Use this table when you have a production problem and need a fast starting point.

Symptom Root Cause Immediate Action Measurable Impact
Old-generation heap grows monotonically across requests Cycle reachable from a root (global, event listener, active closure) DevTools → Memory → Heap Snapshot → Comparison view; filter Delta > 0, trace shortest path to GC root Heap should drop by 40–150 MB after root is nullified and GC runs
GC pauses spike above 150 ms during idle periods Unbounded cache or setInterval retaining lexical scope node --trace-gc --trace-gc-ignore-scavenger app.js; look for Mark-Compact taking > 100 ms Pause time should normalize to < 10 ms after cache is bounded
Heap does not shrink after obj = null or delete obj.prop Tracing GC defers reclamation to next collection cycle Run node --expose-gc -e "global.gc()" to force a cycle in an isolated test; never in production process.memoryUsage().heapUsed should decrease by the object’s retained size in KB/MB
WeakRef.deref() never returns undefined in tests Object still reachable via a secondary strong reference Audit all paths: globals, module-level caches, event listeners. Use DevTools → Memory → Heap Snapshot → Retainers panel After removing the secondary root, deref() returns undefined within 1–2 GC cycles
Removing delete calls speeds up code noticeably delete triggers V8 to deoptimize the object’s hidden class Replace delete obj.prop with obj.prop = undefined where property must stay on the shape Eliminates hidden-class transition; property access reverts to monomorphic speed

Root Cause Explanation: Why Tracing GC Replaced Reference Counting

Reference counting tracks a per-object integer: every time a pointer to an object is created the count rises; every time a pointer is dropped the count falls. When the count reaches zero the memory is freed immediately. The approach sounds appealing — reclamation is instant and deterministic — but it breaks in two fundamental ways that are unavoidable in JavaScript.

The cycle problem. Consider two objects that reference each other:

const a = { id: 'nodeA' };
const b = { id: 'nodeB' };
a.child = b;  // a holds a reference to b → b's count = 1
b.parent = a; // b holds a reference to a → a's count = 1
// Even after both local variables go out of scope,
// each object's count stays at 1 — neither is ever freed.

In a reference-counted engine this pair leaks forever. Early Internet Explorer (before IE 8) used reference counting for COM-based DOM nodes while the JavaScript heap used a different strategy; bridging those two worlds produced exactly these silent, unfixable leaks. The mark-and-sweep algorithm solved the cycle problem by tracing from roots instead of counting pointers: if an object is unreachable from any root, it is dead regardless of how many pointers it holds to itself.

The assignment overhead problem. Every time a reference is assigned or drops out of scope, a reference-counted runtime must atomically increment or decrement a counter. In single-threaded JavaScript that may seem cheap, but at millions of property assignments per second the cache misses on those counters add up to measurable latency spikes on the main thread. V8 instead pays the cost of collection as a periodic, schedulable event that can be moved to concurrent and idle threads.

V8’s tracing collector operates in three coordinated phases. The Young Generation (also called the nursery) is collected by Scavenge — a fast copying collector that runs every few seconds on a 1–8 MB semi-space. Objects that survive two Scavenges are promoted to the Old Generation, where a concurrent Mark-Compact cycle runs during idle time, spreading work across background threads using write barriers to track pointer mutations during live JavaScript execution. This design is explained in detail on the V8 heap layout and memory segments page.

The key consequence for debugging: cycles alone do not leak in V8. Leaks only occur when a cycle remains reachable from a GC root — a global variable, a registered event listener, a module-level cache, or an active closure. Diagnosing a leak therefore means finding the root path, not hunting for cycles.


Reference Counting vs V8 Tracing GC Left side shows two objects A and B with arrows forming a cycle, labeled "Reference Counting: cycle count never reaches zero — leaked forever". Right side shows V8's GC root scan: global scope root with an arrow that does NOT reach the isolated A–B cycle, labeled "Tracing GC: cycle is unreachable from roots — both objects collected." Reference Counting Object A Object B count = 1 count = 1 Count never reaches 0 Leaked forever No local variables remain — but neither object is freed. V8 Tracing GC GC Root reachable Live Obj Obj A Obj B Unreachable from root Both objects collected

Step-by-Step Fix: Diagnosing a Suspected GC Root Leak

Step 1 — Enable GC tracing in Node.js

# Run your app with full GC event logging; --trace-gc-ignore-scavenger
# suppresses the noisy Young Generation events so only Old-Gen cycles show.
node --trace-gc --trace-gc-ignore-scavenger app.js

Expected output: Lines such as [GC] 2847 ms: Mark-Compact 512 MB → 280 MB appearing every 30–120 seconds under steady load. A healthy run shows old-generation size stabilizing within ±5 % of baseline after three consecutive Mark-Compact cycles. Monotonically increasing old-generation size after each cycle confirms a root-path leak.

Step 2 — Capture comparative heap snapshots

Open DevTools → Memory → Heap Snapshot. Take Snapshot A (idle baseline). Execute the workload — navigate, click, or issue requests — that reproduces the suspected leak. Click the trash-can icon (Force GC). Take Snapshot B.

Switch the view dropdown to Comparison. Set the filter to Delta > 0. Expand (closure), (system), or (string) entries to identify which constructor or closure type is growing. Click a retained node and inspect the Retainers panel at the bottom to trace the shortest path back to the GC root.

Verification checkpoint: After you nullify the root path and repeat the workload + GC + snapshot sequence, the # New column for the leaking type should drop to 0.

Step 3 — Trace the retainer path programmatically

Use the --expose-gc flag in an isolated Node.js script to force collection at a known point and measure the before/after heap delta:

// Force-GC probe — Node.js only, never run in production.
// Start with: node --expose-gc probe.js

const before = process.memoryUsage().heapUsed; // bytes before GC

// Simulate the code path that is suspected of leaking.
for (let i = 0; i < 1000; i++) {
  const a = { id: i };
  const b = { ref: a };
  a.ref = b; // circular — but no root retains these after the loop body exits
}

global.gc(); // force a full Mark-Compact cycle

const after = process.memoryUsage().heapUsed; // bytes after GC
const deltaKB = ((before - after) / 1024).toFixed(1);
console.log(`Freed ${deltaKB} KB — cycles collected by tracing GC`);
// Expected: positive delta confirming objects were collected.
// If delta is near 0, a root path still holds the cycle.

Step 4 — Verify with a WeakRef probe

// WeakRef probe — use in an isolated test to confirm tracing GC behavior.
// If reference counting were in use, deref() would always return the object
// because the internal count never reaches zero when a cycle exists.

let a = { payload: new Array(50_000).fill('x') }; // ~400 KB
let b = { ref: a };
a.ref = b; // mutual cycle

const probe = new WeakRef(a); // weak — does not prevent collection

a = null; // drop strong reference from 'a'
b = null; // drop strong reference from 'b'; cycle now isolated from roots

// After GC runs, probe.deref() must return undefined.
// GC timing is non-deterministic — in a real test use --expose-gc.
setTimeout(() => {
  const result = probe.deref();
  console.log(result === undefined
    ? 'Collected — tracing GC confirmed, cycle is not a leak'
    : 'Still live — check for a secondary strong reference');
}, 5000); // 5 s gives V8 time to run idle-time GC

Expected output: Collected — tracing GC confirmed, cycle is not a leak. If you see Still live, open DevTools → Memory → Heap Snapshot, find the object in the snapshot, and inspect the Retainers panel to identify what still holds it.


Runnable Code Reference

Use-case: Explicitly break retainers in a resource pool

// Verifiable cleanup pattern — explicitly clears all strong references
// so the tracing collector's root scan finds zero paths into the pool.

class ResourcePool {
  #active = new Set(); // private field — not leaked to outer scope

  add(resource) {
    this.#active.add(resource); // retain strongly while active
  }

  dispose() {
    for (const resource of this.#active) {
      resource.cleanup?.(); // call teardown hook if present
    }
    this.#active.clear(); // sever all strong references in one call
    // After the next Mark-Compact cycle, pooled objects are collected.
    // Expected heap reduction: 30–80 MB for large resource sets.
  }
}

Use-case: Avoiding the hidden-class penalty of delete

// WRONG — delete triggers V8 to convert the object to a slow "dictionary"
// mode, deoptimizing all property accesses on that object's hidden class.
function clearExpired(obj) {
  delete obj.cachedValue; // deoptimizes hidden class → slower access
}

// RIGHT — set to undefined instead. The property key stays on the shape;
// V8 keeps the object in fast mode. The GC collects the old value.
function clearExpiredFast(obj) {
  obj.cachedValue = undefined; // preserves hidden class → fast access
}

Verification and Regression Prevention

Confirming the fix worked:

  • DevTools → Memory → Heap Snapshot → Comparison view: the leaking constructor’s # New column reads 0 after workload + forced GC.
  • process.memoryUsage().heapUsed in Node.js stabilizes within ±5 % of baseline over three consecutive Mark-Compact cycles (visible in --trace-gc output).
  • GC pause durations shown in --trace-gc output drop below 50 ms and stay there under production-representative load.

Preventing recurrence:

Add a memory-budget assertion to your CI pipeline using the --expose-gc flag in a dedicated test:

// memory-budget.test.js — run with: node --expose-gc memory-budget.test.js
// Fails the build if heap growth exceeds the declared threshold.

const THRESHOLD_MB = 10; // acceptable retained size after workload

async function runWorkload() {
  // Replace with the actual operation under test.
  for (let i = 0; i < 5000; i++) {
    const item = { id: i, data: new Array(100).fill(0) };
    void item; // immediately unreachable — should be collected
  }
}

const before = process.memoryUsage().heapUsed;
await runWorkload();
global.gc(); // force collection
const growthMB = (process.memoryUsage().heapUsed - before) / 1_048_576;

if (growthMB > THRESHOLD_MB) {
  console.error(`FAIL: heap grew by ${growthMB.toFixed(2)} MB (limit: ${THRESHOLD_MB} MB)`);
  process.exitCode = 1;
} else {
  console.log(`PASS: heap grew by ${growthMB.toFixed(2)} MB`);
}

Add this script to your package.json test suite and wire it into your CI configuration. The --expose-gc flag is safe in test environments; never enable it in a production Node.js process.


FAQ

Does JavaScript use reference counting for garbage collection?

No. All modern JavaScript engines — V8 (Chrome and Node.js), SpiderMonkey (Firefox), and JavaScriptCore (Safari) — use tracing garbage collectors, not reference counting. Reference counting was used in some early browser DOM implementations but was abandoned because it cannot reclaim circular references and imposes measurable overhead on every property assignment. The current standard is a generational, concurrent mark-and-sweep algorithm that handles cycles automatically.

Can circular references still cause memory leaks in modern JavaScript?

Yes, but only when the cycle remains reachable from a GC root — a global variable, a registered event listener, a module-level singleton, or an active closure. V8 handles isolated cycles natively; leaks occur when application code inadvertently keeps a root path open to the cycle. The fix is always to break the root reference, not to break the cycle itself.

Why does heap memory not drop immediately after I set a variable to null?

Tracing garbage collectors run on a schedule tuned for throughput and low pause times. Setting a variable to null makes the object eligible for collection but does not trigger an immediate collection cycle. Reclamation happens during the next Scavenge (for Young Generation objects) or Mark-Compact (for Old Generation objects). To force a collection during debugging, start Node.js with --expose-gc and call global.gc(). Never do this in production — it bypasses V8’s optimized scheduling and causes an unnecessary pause.