How Mark-and-Sweep Garbage Collection Works in JavaScript

Mark-and-sweep is the foundational tracing algorithm powering V8’s memory reclamation, and understanding it is essential for anyone working through JavaScript Memory Fundamentals & Runtime Mechanics. Unlike legacy reference-counting strategies, it resolves cyclic reference leaks by periodically traversing the entire object graph from known roots. This guide covers the algorithmic phases, V8’s generational extensions, heap compaction trade-offs, and the precise DevTools workflows you need to confirm reclamation is actually happening — including a child page on what causes memory fragmentation in V8.


Conceptual Grounding: How the Tri-Color Algorithm Works

The classical mark-and-sweep algorithm assigns each heap object one of three logical states: white (not yet visited), gray (discovered but children not yet scanned), and black (fully processed). The collector maintains a worklist of gray objects and drains it iteratively — pushing discovered children from white to gray, then graduating the current object to black. When the worklist empties, every remaining white object is unreachable and eligible for reclamation during the sweep phase.

V8’s implementation, called Orinoco, extends this with incremental and concurrent marking. Instead of stopping the main thread for the full duration, the marker runs in short 1–5 ms increments interleaved with JavaScript execution, using write barriers to track any heap mutations that occur between increments. This keeps individual GC pauses below 10 ms on most workloads while the background threads concurrently trace the majority of the graph.

The sweep phase reclaims unmarked slots and threads them onto free-list chains. V8 also runs a concurrent sweeper on worker threads, so the main thread resumes immediately after marking while sweeping continues in parallel. The separation of concerns between marking (correctness) and sweeping (performance) is why most heap allocations continue without noticeable pauses even during a major GC cycle.

Understanding where objects start their lives — on the stack vs heap — is a prerequisite, because the marking phase begins from stack-allocated references and CPU register values that point into the heap.


V8 Mark-and-Sweep: Tri-Color Object Graph Traversal Diagram showing the three phases of V8's mark-and-sweep algorithm: roots discovery (white objects), gray worklist draining (tri-color marking), and sweep/reclamation of unreachable white objects. V8 Tri-Color Mark-and-Sweep Phases Phase 1: Roots Phase 2: Mark (Tri-Color Traversal) Phase 3: Sweep GC Roots stack · registers · globals white white gray gray black black black unreachable cycle retained freed retained freed retained → free-list → free-list white = not yet visited gray = discovered black = fully scanned

Diagnostic Workflow: Verifying GC Reclamation

Use this workflow in Chrome DevTools or Node.js Inspector to confirm that mark-and-sweep is actually reclaiming the objects you expect.

Step 1 — Establish a baseline. Open DevTools → Memory → Heap Snapshot and click Take snapshot before executing the target workload. Record the Total heap size and Used heap size values displayed in the snapshot header (e.g., Total: 12.4 MB, Used: 9.1 MB).

Step 2 — Trigger the allocation. Execute the function, component lifecycle, or data-fetching routine suspected of holding excess memory. For accuracy, repeat the action to flush JIT warm-up allocations that inflate a first-run baseline.

Step 3 — Force a major GC cycle.

  • Chrome/Edge: Click the Collect garbage icon (trash-can) in the DevTools Memory panel toolbar. This requests a full major GC including the old generation.
  • Node.js: Launch with --expose-gc and call global.gc() from your script or the inspector’s Runtime.evaluate. Never expose this in production — it pauses the event loop synchronously.

Step 4 — Capture a delta snapshot. Immediately after GC completes, take a second snapshot via DevTools → Memory → Heap Snapshot → Take snapshot. Wait for the profiler to finish serialising before proceeding.

Step 5 — Switch to Comparison view. In the snapshots sidebar, select the second snapshot, then set the view dropdown to Comparison. Filter by the constructor or class name of the objects you allocated. The # Delta column shows net object count change; Size Delta shows bytes.

Expected metric: For objects you expect to be released, # Delta should be zero or negative and Size Delta should be negative (e.g., -8,192 KB for an 8 MB array that was freed).

Step 6 — Trace retainers for any persistent delta. Expand any retained instances and inspect the Retainers pane (bottom panel of DevTools → Memory). Walk the reference chain upward until you reach the root holding the object alive. Common culprits: closures capturing outer scope variables, event listeners that were never removed, or detached DOM nodes referenced by a live JavaScript variable.

Step 7 — Validate with --trace-gc in Node.js. Add --trace-gc to your Node.js launch command. Each line emitted to stderr reports the GC type (Scavenge for minor, Mark-sweep or Mark-compact for major), the heap size before and after, and the pause duration in milliseconds. For a correctly operating application, major GC events should reduce Used heap by 30–70% of Total heap on typical workloads.


Code Patterns & Signatures

Detect reference detachment and verify reclamation in Node.js with --expose-gc and --trace-gc.

// Run with: node --expose-gc --trace-gc verify_gc.js
// Purpose: confirm that nulling a reference makes the allocation
// eligible for collection and is recovered during the next major GC.

const { heapUsed: initial } = process.memoryUsage();
console.log(`Baseline heapUsed: ${(initial / 1024 / 1024).toFixed(2)} MB`);

// Allocate approximately 8 MB of object references in OldSpace
// (fill() with an object literal forces V8 to allocate each slot)
let largeArray = new Array(1_000_000).fill(null).map((_, i) => ({
  id: i,
  payload: new Uint8Array(8), // 8 bytes per element = ~8 MB total
}));

const { heapUsed: afterAlloc } = process.memoryUsage();
console.log(`Post-allocation heapUsed: ${(afterAlloc / 1024 / 1024).toFixed(2)} MB`);

// Sever the only strong reference so the array becomes unreachable
largeArray = null;

// Invoke a full major GC cycle synchronously (test/debugging use only)
global.gc();

const { heapUsed: final } = process.memoryUsage();
console.log(`Post-GC heapUsed: ${(final / 1024 / 1024).toFixed(2)} MB`);
console.log(`Reclamation delta: ${((final - initial) / 1024 / 1024).toFixed(2)} MB`);
// Expected: delta close to 0 MB (±1 MB for V8 internal metadata).
// If delta remains ~8 MB, a hidden reference is still holding the array.

Track GC events programmatically using the v8 built-in module — useful for alerting when major cycles are too frequent.

// Purpose: observe GC events in a long-running Node.js process
// without requiring --expose-gc or blocking the event loop.
const v8 = require('v8');
const { PerformanceObserver, constants } = require('perf_hooks');

// GC_FLAGS masks: 1 = minor (Scavenge), 2 = major (Mark-sweep),
// 4 = incremental marking step, 8 = weak-ref processing
const GC_MAJOR = constants.NODE_PERFORMANCE_GC_MAJOR;

const obs = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    const isMajor = entry.detail.kind === GC_MAJOR;
    if (isMajor) {
      // Log major GC pause duration; alert if > 100 ms
      const pause = entry.duration.toFixed(1);
      console.log(`[GC] Major cycle: ${pause} ms`);
      if (entry.duration > 100) {
        console.warn('[GC] Warning: major GC pause > 100 ms — check for heap growth');
      }
    }
  }
});

obs.observe({ entryTypes: ['gc'] });

// v8.getHeapStatistics() returns a snapshot of heap state
// without triggering GC — safe to call from a monitoring interval
setInterval(() => {
  const stats = v8.getHeapStatistics();
  const used = (stats.used_heap_size / 1024 / 1024).toFixed(1);
  const total = (stats.total_heap_size / 1024 / 1024).toFixed(1);
  const limit = (stats.heap_size_limit / 1024 / 1024).toFixed(0);
  console.log(`Heap: ${used} MB used / ${total} MB total / ${limit} MB limit`);
}, 5000);

Use the --max-old-space-size and --max-semi-space-size flags to control when major and minor GC cycles are triggered.

# Limit old-generation heap to 512 MB — forces earlier major GC cycles,
# useful to reproduce OOM conditions locally that match a constrained container.
node --max-old-space-size=512 server.js

# Increase young-generation (semi-space) to 64 MB per semi-space (128 MB total)
# to reduce Scavenge frequency in allocation-heavy workloads like SSR.
node --max-semi-space-size=64 server.js

# Combine both: constrained old-gen + larger young-gen for SSR profiling.
# --trace-gc emits one line per GC event (type, heap before/after, pause ms)
node --max-old-space-size=512 --max-semi-space-size=64 --trace-gc server.js

V8 Generational Collection: NewSpace and OldSpace

V8 divides the heap into distinct generational regions — a design you can explore in detail through understanding the V8 heap layout and memory segments. The key consequence for GC behaviour is:

NewSpace (young generation): Typically 1–8 MB total (two equal semi-spaces). V8 collects it with a fast Scavenger (copying collector) that promotes surviving objects to OldSpace after one or two cycles. Scavenge pauses typically run under 1 ms.

OldSpace (old generation): Holds promoted long-lived objects. A full mark-and-sweep cycle here can scan hundreds of megabytes and, without incremental/concurrent marking, would pause the main thread for 50–200 ms on large heaps. Orinoco’s concurrent marker reduces main-thread pause to under 10 ms for most production workloads.

LargeObjectSpace: Objects above ~512 KB are allocated here and never moved — compaction skips them to avoid the cost of copying large buffers.

The weak generational hypothesis — “most objects die young” — is why tuning --max-semi-space-size matters in allocation-heavy applications. If the young generation is too small for your allocation rate, objects promote to OldSpace prematurely and inflate major GC frequency. A 2–4× increase in semi-space size (e.g., from the default 16 MB to 32–64 MB per semi-space) can reduce major GC events by 40–60% in SSR workloads.

Write barriers and remembered sets are the infrastructure that makes generational collection safe. When old-generation code writes a pointer to a young-generation object, V8’s write barrier records the cross-generational pointer in a remembered set. During Scavenge, these remembered-set entries are added to the root set so NewSpace roots from OldSpace are not missed.


Symptom-to-Fix Reference Table

Symptom Root Cause Immediate Action Measurable Impact
Major GC pause > 100 ms on every request OldSpace growing unboundedly; full mark-and-sweep forced frequently Run --trace-gc; identify object constructor in DevTools → Memory → Heap Snapshot → Comparison view Pause should drop below 20 ms after fixing the retention; major GC frequency should fall to < 1 per minute on steady traffic
heapUsed grows linearly after each request, never decreasing Strong reference held by a closure, event listener, or module-level cache In DevTools → Memory → Heap Snapshot → Comparison view, sort by Size Delta descending; trace Retainers pane heapUsed plateau should stabilise within ±5% over 50 requests after detaching the reference
Forcing global.gc() does not reduce heap size Reference is still reachable — either from a global, a live timer, or an active Promise chain Use DevTools → Memory → Heap Snapshot → Retainers to identify the root holding the object Post-GC heap drops to within 2–3 MB of baseline after severing the root reference
Scavenge pauses every 10–20 ms in an SSR workload NewSpace too small; allocation rate causes constant young-generation collections Increase --max-semi-space-size from default (16 MB) to 32 or 64 MB; verify with --trace-gc Scavenge frequency drops 50–70%; minor GC pauses aggregate to < 5 ms per second
heap_size_limit reached → process exit with FATAL ERROR: CALL_AND_RETRY_LAST Allocation failed Old-generation exhausted; no room for promotion from NewSpace Increase --max-old-space-size as a short-term fix; diagnose retention source with heap snapshot Process survives; long-term: fixing the retention root eliminates the OOM entirely
DevTools Comparison view shows large retained (array) or (object elements) after a navigation Framework component failed to detach a reactive store subscription or global event listener Check useEffect cleanup, onUnmounted, or ngOnDestroy lifecycle hooks; verify removal of subscriptions Retained (array) delta returns to 0 after the cleanup hook is implemented and navigation repeated
Memory grows during development but not in production DevTools extension or React DevTools retaining references for inspection Profile in a clean browser profile with extensions disabled; compare heap sizes Retained size from extension roots drops to zero; baseline confirmed production-equivalent
--expose-gc / global.gc() in production causes event-loop stalls Synchronous major GC blocks all I/O; misuse of test-only API Remove global.gc() from production code; monitor with PerformanceObserver GC entries instead Event-loop latency returns to normal; GC monitoring continues without stalls

Edge Cases & Gotchas

V8 lazy GC masking retention. V8 schedules major GC based on heap pressure, not on programmer intent. In low-traffic periods, OldSpace may not fill enough to trigger a major cycle, so a long-term retention bug goes undetected until traffic spikes. Always force GC explicitly in test scenarios with global.gc() (Node.js) or the DevTools toolbar to surface retention bugs before production load does.

Incremental marking write-barrier overhead. Orinoco’s write barriers are cheap (< 1 ns per write in JIT-compiled code) but can accumulate in hot inner loops performing thousands of pointer stores per millisecond. If CPU profiles show WriteBarrier_* or RecordWrite_* entries consuming more than 2–3% of total time, consider restructuring data layouts to batch mutations or use typed arrays (which have no pointer-based write barriers).

Pointer compression skewing retained-size calculations. On 64-bit V8 builds with pointer compression enabled (default in Chrome since M80), internal pointers occupy 4 bytes rather than 8 bytes. DevTools heap snapshot retained-size figures reflect the compressed representation. This means a retained-size of 4 bytes per pointer field is correct — not an off-by-half error in the profiler.

Extension contexts inflating browser heap. Browser extensions inject content scripts into every inspected tab and contribute their own heap objects to the V8 isolate. When profiling under extensions, retained size totals can be inflated by 2–20 MB per extension. Profile in a clean browser profile (--user-data-dir=$(mktemp -d)) or use incognito mode with extensions blocked.

WeakRef and FinalizationRegistry deferring reclamation unpredictably. WeakRef does not prevent collection, but V8 is not required to collect the target before the next major GC. Code that assumes immediate reclamation after weakRef.deref() returns undefined may observe unexpected heap growth between GC cycles. Design cache eviction logic around FinalizationRegistry callbacks rather than assuming synchronous reclamation. Note that closure memory leaks can silently prevent WeakRef targets from being cleared if a strong reference chain exists elsewhere.


FAQ

How does mark-and-sweep handle circular references in JavaScript?

Mark-and-sweep starts from GC roots (global object, stack frames, registers) and traverses only objects reachable by following strong reference chains. Circular references that are not reachable from any root remain white throughout the marking phase and are reclaimed during sweep. This is the fundamental advantage over reference counting: no cycle-detection bookkeeping is needed. The algorithm’s correctness proof depends only on the reachability invariant — if you cannot reach an object from a root, it is dead.

Why does heap usage sometimes increase immediately after forcing garbage collection?

V8 may allocate compaction metadata, update remembered sets, or pre-size free-list structures during or immediately after a major cycle. The process.memoryUsage().heapUsed value is a snapshot of the allocator’s internal accounting, not a direct measure of live objects. Additionally, V8 may speculate a new allocation is about to happen and pre-expand a space. Take multiple snapshots across a steady-state workload rather than comparing a single before/after pair: if heapUsed stabilises rather than growing monotonically, GC is functioning correctly.

Can developers manually trigger mark-and-sweep in browsers?

Browser environments intentionally hide GC scheduling to prevent timing side-channel attacks and to give the runtime freedom to optimise. The Collect garbage button in DevTools → Memory sends a hint to the V8 runtime requesting a major GC, but it is advisory. In Node.js, global.gc() with --expose-gc forces a synchronous major cycle, which is useful for deterministic testing — but must never be used in production because it blocks the event loop for the full duration of marking and sweeping.

What is the difference between a minor GC (Scavenge) and a major GC in V8?

A minor GC (Scavenge) operates only on NewSpace (typically 2–16 MB total) using a fast copying algorithm. It runs in under 1 ms and handles the high-frequency allocation of short-lived objects. A major GC performs the full tri-color mark-and-sweep over both NewSpace and OldSpace (up to the --max-old-space-size limit, defaulting to 1.5 GB on 64-bit systems). With concurrent marking enabled, main-thread pauses during a major cycle are typically 5–20 ms; without it, they can reach 100–300 ms on a 500 MB heap. The --trace-gc flag distinguishes the two: look for Scavenge vs Mark-sweep or Mark-compact in the output lines.