Reference Counting vs Tracing GC Algorithms in JavaScript
Two fundamentally different strategies govern how JavaScript runtimes decide which heap objects to reclaim: reference counting and tracing collection. Understanding the mechanical difference matters for every frontend and Node.js engineer who has ever stared at a monotonically growing heap and wondered why memory never came back. This page is part of JavaScript Memory Fundamentals & Runtime Mechanics. For related operational context, the how mark-and-sweep garbage collection works page and the does JavaScript use reference counting for GC companion page complement what you will find here.
Conceptual Grounding: Two Models, One Goal
Both algorithms solve the same problem — identifying which heap objects are safe to free — but from opposite starting points.
Reference counting attaches an integer counter to every object. The runtime increments that counter each time a new reference is created (assignment, function argument passing, property set) and decrements it each time a reference is dropped (variable goes out of scope, property is deleted, closure is released). When the counter reaches zero the memory is immediately eligible for reclamation.
Tracing collection ignores per-object counters entirely. Instead, the collector starts from a fixed set of roots — the global object, the active call stack, CPU registers holding pointers, and any internal engine handles — and traverses every pointer, following the object graph outward. Objects the traversal reaches are “reachable” and kept alive. Everything the traversal never touches is considered garbage and swept.
The conceptual gap between these two models has enormous practical consequences: timing of reclamation, overhead distribution, cyclic reference handling, and pause predictability all differ substantially.
The Algorithmic Asymmetry at a Glance
The Cyclic Reference Problem
The fatal flaw of reference counting is its inability to resolve isolated cycles. Consider two objects that reference each other but are no longer reachable from any live scope. Each object’s counter stays at one — it counts the incoming pointer from the other object in the cycle — so neither counter ever reaches zero. The memory leaks silently, indefinitely.
function createCycle() {
const a = { name: 'A' };
const b = { name: 'B' };
// Each object holds a reference to the other.
// In a pure RC engine, a.refCount = 1 and b.refCount = 1
// both remain non-zero even after createCycle() returns,
// because the mutual references keep each other "alive".
a.ref = b;
b.ref = a;
// Returning null drops the only external references (a and b locals).
// A tracing collector will reclaim both on the next sweep
// because neither is reachable from the root set.
return null;
}
createCycle();
// In V8 (tracing): both objects are collected at the next minor or major GC.
// In a pure RC engine: both objects leak permanently.
Tracing algorithms are immune to this problem. Reachability, not pointer counts, is the only criterion. Objects A and B in the cycle above are never visited by a root traversal, so they remain “white” in V8’s tri-color marking scheme and are swept without any special cycle-detection logic.
V8’s Orinoco concurrent marker uses exactly this tri-color scheme:
- White — not yet visited; eligible for collection if the mark phase ends without visiting.
- Gray — discovered (a pointer to it exists in a marked object) but its own outgoing pointers have not all been scanned yet.
- Black — fully scanned; both the object and all its descendants are reachable.
The transition from gray to black is the core of the concurrent marking work. Write barriers ensure that any new pointer written during concurrent marking is correctly tracked, preventing the collector from missing objects that became reachable after it started.
Diagnostic Workflow: Verifying Tracing GC Behaviour
Follow this exact sequence to confirm that V8’s tracing collector is running as expected and that suspected cycles are genuinely being reclaimed.
Step 1 — Establish a heap baseline.
Open DevTools → Memory tab. Select the “Heap snapshot” radio button. Click “Take snapshot” with the application in an idle state. Note usedJSHeapSize and totalJSHeapSize from the Summary view. In Node.js you can read process.memoryUsage() instead. Record these as your clean baseline.
Expected metric: usedJSHeapSize should be stable across two consecutive idle snapshots (delta < 1 MB). Growing between idle snapshots indicates a background retention path.
Step 2 — Force controlled allocation. Execute a workload that exercises the suspected allocation path — for example, mount and unmount a heavy React component 50 times, or run a data-processing loop that creates many intermediate objects. Then force a GC:
- Node.js: launch with
node --expose-gc app.js, then callglobal.gc()in your script or REPL. - Chrome: click the trash-can “Collect garbage” icon in DevTools → Memory, or run the app with
--js-flags="--expose-gc"and callwindow.gc()in the console.
Step 3 — Capture and diff heap snapshots.
Take Snapshot B immediately after GC completes. In DevTools → Memory → Snapshot B → switch the dropdown from “Summary” to “Comparison” and select Snapshot A as the baseline. Filter the constructor list by typing (detached) to surface detached DOM nodes, or Closure to expose retained closures.
Expected metric: If the workload was clean, the HTMLDivElement delta between Snapshot A and post-GC Snapshot B should be less than 5% of the component count. A delta above 15% strongly suggests a retention path that the mark phase cannot traverse.
Step 4 — Confirm via --trace-gc output.
Run Node.js with node --trace-gc app.js and reproduce the workload. Look for lines like:
[12345:0x7f1a2b3c4d50] 840 ms: Scavenge 14.2 (18.0) -> 6.1 (18.0) MB, 1.4 / 0.0 ms
[12345:0x7f1a2b3c4d50] 1203 ms: Mark-Sweep 18.0 (32.0) -> 12.6 (32.0) MB, 4.8 / 0.0 ms
Scavenge is a minor GC targeting new space (short-lived allocations). Mark-Sweep or Mark-Compact is a major GC sweeping old space. Both confirm that tracing phases are running. The numbers in the format before (total) -> after (total) MB show heap-size deltas in MB.
Step 5 — Inspect retainer paths for persistent objects.
In Snapshot B Comparison view, click any object with unexpectedly high retained size. Expand its “Retainers” tree in the bottom panel. Trace the retaining chain back to a root: Window, Global, ReactRoot, or an event listener registry. Any chain terminating at (GC roots) > global is a genuine retention — the mark phase traversed it, so the object is alive by design. Any chain terminating in a framework hook or detached context is a candidate leak.
Code Patterns and Signatures
Use-case: Demonstrate that isolated cycles are collected by V8 and verify it with --trace-gc.
// Isolated cycle — safe in V8, a permanent leak in a pure RC engine.
function createAndReleaseCycle() {
const node1 = { id: 'node1' };
const node2 = { id: 'node2' };
// Mutual reference: node1 holds node2, node2 holds node1.
node1.sibling = node2;
node2.sibling = node1;
// Both local variables go out of scope here.
// V8's next GC traversal will find neither reachable from roots
// and reclaim both. A counter-based engine would keep both forever.
}
createAndReleaseCycle();
// Run with: node --expose-gc --trace-gc cycle-test.js
// After global.gc() you will see a Scavenge or Mark-Sweep event
// and heap-after will be smaller than heap-before.
if (typeof global.gc === 'function') {
global.gc(); // force a collection cycle for demonstration
}
Use-case: Replace strong object references with WeakMap so the tracing collector can reclaim entries whose key objects are no longer externally reachable.
// WeakMap-backed metadata cache — GC-friendly, no manual cleanup required.
const domMetadata = new WeakMap();
function attachRenderMetadata(element, data) {
// WeakMap holds a *weak* key reference: the entry is not counted
// as a retaining path from domMetadata to element.
// When element is removed from the DOM and all other references
// are dropped, the tracing GC will collect it AND the WeakMap entry.
domMetadata.set(element, { ...data, timestamp: Date.now() });
}
function getRenderMetadata(element) {
return domMetadata.get(element); // returns undefined if already collected
}
// Verify in DevTools → Memory → Heap Snapshot:
// after removing the DOM node and forcing GC, search for the element
// constructor name — it should no longer appear in the snapshot.
Use-case: Monitor generational GC activity in Node.js to distinguish minor from major collection events.
const { PerformanceObserver, constants } = require('perf_hooks');
// Subscribe to V8 garbage-collection performance entries.
const obs = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
// entry.detail.kind: 1 = minor (Scavenge), 2 = major (Mark-Sweep/Compact)
const kind = entry.detail.kind === constants.NODE_PERFORMANCE_GC_MINOR
? 'Scavenge (minor)'
: 'Mark-Sweep (major)';
// duration is in milliseconds; flag major GC pauses above 20ms
const flag = entry.duration > 20 ? ' ⚠ long pause' : '';
console.log(`GC [${kind}] ${entry.duration.toFixed(2)} ms${flag}`);
}
});
obs.observe({ entryTypes: ['gc'] });
// Any sustained major GC frequency above ~6/minute under steady load
// indicates excessive old-space promotion — review long-lived allocations.
Symptom-to-Fix Reference Table
| Symptom | Root Cause | Immediate Action | Measurable Impact |
|---|---|---|---|
usedJSHeapSize grows monotonically across 10+ navigation events |
Strong reference chain prevents tracing GC from reclaiming route components | Open DevTools → Memory → Comparison snapshot; filter (detached). Find the retaining root and remove the strong reference (event listener, closure, or global cache entry). |
usedJSHeapSize stabilises within ±5% of baseline after 3 route cycles |
Major GC (Mark-Sweep) firing more than once every 10 seconds under steady load |
Excessive object promotion to old space; short-lived objects escaping new space via closures or long-lived array push | Run node --trace-gc and correlate promotion with allocation sites. Restructure to avoid capturing large objects inside long-lived closures. |
Major GC frequency drops to < 1 per 30 seconds |
Snapshot shows thousands of Detached HTMLDivElement objects |
DOM nodes removed from the tree but retained by a JS reference (event listener closure or framework context) | Identify the retaining root in Snapshot Retainers pane. Remove the listener with removeEventListener or null out the reference in the component teardown callback. |
Detached node count returns to < 10 after a GC cycle |
WeakMap entries appear to persist across forced GC cycles in DevTools |
The key object is still reachable from an unexpected root (e.g. a debug variable or DevTools console reference) | Type undefined in the DevTools console to clear the $_ last-expression reference. Retake the snapshot and re-check. |
WeakMap entry count drops to zero after GC with cleared console |
| Main-thread jank spike of 50–150 ms correlated with GC in Performance timeline | Full Mark-Compact triggered by old-space exhaustion; insufficient heap budget for the application’s steady-state live set | Increase --max-old-space-size (e.g. --max-old-space-size=512 for 512 MB) and re-profile. If the live set genuinely requires more space, use streaming or pagination to reduce concurrent live objects. |
Major GC pauses drop below 20 ms; Lighthouse TBT improves |
node process exits with FATAL ERROR: CALL_AND_RETRY_LAST Allocation failed - JavaScript heap out of memory |
Old-space live set exceeds the configured heap limit before the next GC completes | Launch with a higher --max-old-space-size, fix the underlying retention, or add a --gc-interval flag for more-frequent collection under load |
Process remains stable across a 60-minute load test |
Heap snapshot shows Array objects with unexpectedly large retained size |
Accumulator pattern: items pushed but never spliced; entire array promoted to old space | Replace unbounded arrays with a WeakRef-keyed structure or a bounded LRU cache. |
Snapshot Array retained size drops to < 10 MB on next capture |
Edge Cases and Gotchas
V8 lazy GC masking retention.
V8 will not always collect immediately on global.gc() — particularly when the heap has sufficient free space and the allocation rate is low. If you call global.gc() and usedJSHeapSize does not drop as expected, call it twice (the first pass finishes incremental marking, the second triggers sweep). In DevTools, click the trash-can icon twice in quick succession.
--expose-gc changes allocation pressure in benchmarks.
Calling global.gc() in hot paths is diagnostic-only. Production Node.js processes must not ship --expose-gc because the explicit calls force synchronous major GC on the main thread, wrecking latency. Use it only in isolated test harnesses.
DevTools console references retaining objects.
Every object evaluated in the DevTools console is held by the $_, $0 – $4 convenience variables. If you evaluate a suspect object and then take a snapshot to check whether it was collected, the snapshot will show it as retained because the console itself is a root. Type undefined in the console before taking the diagnostic snapshot.
Pointer compression skewing retained-size readings.
V8 enables pointer compression on 64-bit platforms (Orinoco), storing heap pointers as 32-bit offsets relative to a cage base. This halves pointer size and can make retained-size numbers in heap snapshots smaller than the true RSS contribution. When comparing snapshot retained sizes to process.memoryUsage().heapUsed, expect a discrepancy of up to 20% on large graphs.
Framework context providers holding detached component trees.
React Context providers, Vue provide/inject hierarchies, and Angular Zone.js contexts maintain internal reference chains. When a child component is unmounted without calling the correct cleanup hook (useEffect cleanup, onUnmounted, ngOnDestroy), the context root can silently retain the entire component subtree. Always verify cleanup in a heap snapshot Comparison view filtered by the component class name.
Frequently Asked Questions
Why did JavaScript engines abandon reference counting?
Pure reference counting cannot collect cyclic references without an auxiliary cycle-detection pass, which effectively makes it a partial tracing algorithm anyway. Beyond cycles, every pointer write — property assignment, argument passing, closure capture — must atomically update a counter, imposing synchronous overhead on the hottest code paths. In multi-threaded or concurrent contexts, counters become shared mutable state requiring locks or atomics. Tracing GC moves this overhead off the mutation path entirely: allocations are cheap (a pointer bump), and collection cost is paid in a background or incremental phase controlled by the engine, not by application code.
How can I verify V8 is using tracing GC in my environment?
Launch Node.js with --trace-gc or open DevTools → Performance → record a trace with the Memory checkbox enabled. In the --trace-gc output, every line will be labelled Scavenge (minor tracing of new space) or Mark-Sweep / Mark-Compact (major tracing of old space). In the Performance timeline, GC events appear as olive-coloured bars in the “Main” thread row. None of these labels correspond to reference-count decrements — they are all tracing phases.
Does WeakRef change the underlying GC algorithm?
No. WeakRef and WeakMap are API-level affordances that integrate with the existing tracing collector. They tell the engine “do not count this reference when determining reachability.” The mark phase still traverses the object graph in exactly the same way — WeakRef targets simply do not have their reachability extended by the weak reference itself. After the mark phase, V8 checks its list of registered WeakRef and FinalizationRegistry entries and nullifies or schedules callbacks for those whose referents are about to be swept.
What is the V8 tri-color marking scheme and why does it matter?
Tri-color marking allows concurrent marking (where the GC and application mutate the heap simultaneously) to remain correct. Objects start white (unvisited). When the GC discovers a pointer to an unvisited object it turns the object gray (reachable but children not yet scanned). When the GC finishes scanning all of an object’s outgoing pointers it turns the object black (fully scanned). A write barrier ensures that if application code stores a pointer to a white object inside a black object during concurrent marking, the white object is immediately re-grayed so the GC does not miss it. This invariant — no black object points directly to a white object at the end of marking — guarantees correctness without a stop-the-world pause for the full mark phase.
Related
- JavaScript Memory Fundamentals & Runtime Mechanics — parent section covering allocation, V8 internals, and GC foundations
- How Mark-and-Sweep Garbage Collection Works — deep dive into the algorithmic phases V8 runs during a major collection
- Understanding the V8 Heap Layout and Memory Segments — new space, old space, code space and how object promotion interacts with GC
- Does JavaScript Use Reference Counting for Garbage Collection? — direct answer to the common misconception, with historical browser context
- How to Tune V8 Garbage Collection Thresholds for SPAs — runtime flags and heap-budget strategies to reduce GC pause frequency