What causes memory fragmentation in the V8 engine

V8 heap fragmentation occurs when scattered free blocks in Old Space cannot satisfy a contiguous allocation request — even though the aggregate free bytes look adequate — causing allocation failures or unexpected major GC pauses. This page is a focused diagnostic for that exact symptom, and sits under how mark-and-sweep garbage collection works inside the broader JavaScript Memory Fundamentals reference.

Symptom-to-Fix Diagnostic Matrix

Symptom Root Cause Immediate Action Measurable Impact
process.memoryUsage().rss climbs steadily, but heapUsed stays flat Native module bloat or V8 metadata overhead, not heap fragmentation Profile native extensions; isolate V8 heap via v8.getHeapStatistics() Identifies non-heap RSS sources; stops false fragmentation diagnosis
total_available_size > 30 % of heap_size_limit but allocations fail or trigger frequent GC Old Space fragmentation — free bytes are non-contiguous Force compaction via global.gc() in staging; audit allocation sizes Contiguous free space recovers; major GC pauses fall 40–60 %
Major GC pauses spike to 100–200 ms under steady load Deferred Mark-Compact phase triggered by fragmentation threshold crossing Implement object pooling; reduce large-object churn Pauses drop to 25–40 ms; P99 latency improves
large_object_space.space_used_size is high but physical_space_size is much larger Allocations > 1 MB bypass standard compaction, fragmenting LOS permanently Switch to slab pooling; avoid repeated >1 MB allocations LOS fragmentation ratio falls below 0.20

Root Cause Explanation

V8 partitions heap memory into the Young Generation (New Space) and the Old Generation (Old Space). New Space uses a semi-space copying collector: every Scavenge cycle copies live objects from the active semi-space into the inactive one, producing a perfectly contiguous free region at zero fragmentation cost. This is why short-lived allocations are cheap.

Old Space works differently. Objects that survive two or more Scavenge cycles are promoted there. The mark-and-sweep algorithm marks live objects in place and then sweeps dead ones, leaving free-list entries scattered wherever dead objects sat. A subsequent Mark-Compact phase moves live objects together to restore contiguity — but V8 deliberately defers this expensive step. Under Orinoco (V8’s incremental GC), compaction only runs when fragmentation crosses an internal threshold (roughly 30–40 % of Old Space), or when memory pressure forces a full cycle.

Between compaction events, the allocator must satisfy requests from a fragmented free list. When the largest contiguous free block is smaller than the requested allocation size, V8 must either trigger an early major GC, grow the heap, or — if the heap is at --max-old-space-size — throw an out-of-memory error.

Three allocation anti-patterns accelerate this drift:

Variable-size medium objects (64 KB–512 KB). These bypass V8’s bump allocator (which only handles small objects) and scatter across Old Space. Staggered deallocations leave gaps that no subsequent same-size object can reuse perfectly, fragmenting free lists.

Large objects (> 1 MB). V8 routes these directly to large_object_space, which is never compacted during standard cycles. Frequent allocation and deallocation of large buffers creates permanent holes in this sub-space.

String slicing and cons-string chains. V8’s internal string representations — cons strings and sliced strings — keep references alive to parent character data. Because the parent and the slice can have different lifetimes, adjacent free blocks cannot be coalesced, preventing the allocator from merging neighbouring gaps.

The net effect is that v8.getHeapStatistics().total_available_size can appear healthy (many free bytes) while every individual free block is too small for the current allocation request. Engineers who only watch RSS or heapUsed miss this entirely; you need v8.getHeapSpaceStatistics() to see the per-space picture.

The diagram below shows how a Scavenge leaves New Space fully compacted while Old Space accumulates holes between promotions.

V8 heap fragmentation: New Space vs Old Space Left side shows New Space after Scavenge — objects packed tightly with one large free block. Right side shows Old Space after mark-sweep — live objects interspersed with scattered free holes. A promotion arrow crosses the boundary. New Space (after Scavenge) Old Space (after mark-sweep) live A live B live C contiguous free region (bump pointer resets here) promote live D hole live E hole hole live F hole live G large_object_space — never compacted (allocations > 1 MB bypass mark-compact) live object free hole LOS

Step-by-Step Fix

Step 1 — Confirm fragmentation with --trace-gc

Run the process with GC tracing, suppressing cheap Scavenge lines so only major-cycle output appears:

# Suppress scavenge noise; show only major Mark-Compact events
node --trace-gc --trace-gc-ignore-scavenger app.js

Expected output when fragmentation is the problem:

[12345:0x1234567] 15000 ms: Mark-Compact 256.4 (312.1) -> 248.1 (312.1) MB, 142.5 / 0.0 ms
  (average mu = 0.180, current mu = 0.120) allocation failure; scavenge might not succeed

The Mark-Compact label confirms a full compaction cycle. Pauses above 100 ms signal that fragmentation forced aggressive work. The allocation failure suffix means the allocator could not find a sufficiently large contiguous block before GC.

Step 2 — Quantify fragmentation ratios per heap space

This script prints the fragmentation ratio for Old Space and large_object_space. Run it immediately after a representative workload, not at startup.

// Use-case: measure per-space fragmentation after a production-representative load
const v8 = require('v8');

const stats = v8.getHeapSpaceStatistics(); // returns one entry per V8 heap space

const spaces = ['old_space', 'large_object_space'];

for (const name of spaces) {
  const s = stats.find(sp => sp.space_name === name);
  if (!s || s.physical_space_size === 0) continue;

  // Ratio > 0.35 = severe; 0.15–0.35 = moderate; < 0.15 = healthy
  const ratio = s.space_available_size / s.physical_space_size;
  console.log(
    `${name}: available=${(s.space_available_size / 1024 / 1024).toFixed(1)} MB  ` +
    `physical=${(s.physical_space_size / 1024 / 1024).toFixed(1)} MB  ` +
    `ratio=${ratio.toFixed(3)}`
  );
}

Verification checkpoint: A ratio above 0.35 confirms significant fragmentation. Healthy production workloads sit below 0.15.

Step 3 — Take sequential heap snapshots to identify churn sources

Signal-based snapshots let you capture the heap at multiple points without restarting the process. Open DevTools → Memory → Heap Snapshot → Comparison view to diff sequential snapshots and pinpoint objects accumulating in Old Space. You can also review how to take and compare heap snapshots in Chrome step-by-step for a full walkthrough.

# Start the app with snapshot-on-signal support
node --heapsnapshot-signal=SIGUSR2 app.js

Then, in a second terminal, send the signal three times at 5-minute intervals, replacing YOUR_PID with the actual process ID:

# Capture snapshot 1 — replace YOUR_PID each time
kill -SIGUSR2 YOUR_PID

Verification checkpoint: In the Comparison view, filter the constructor column by (array) and (string). Objects with a high positive #Delta that survive across all three snapshots are being promoted to Old Space faster than they are released — prime fragmentation candidates.

Step 4 — Force a baseline compaction and measure delta

// Use-case: Force GC and capture heap delta in Node.js to quantify reclaimable fragmentation
// Run with: node --expose-gc measure-fragmentation.js

const v8 = require('v8');

// Trigger full Mark-Compact to establish a post-compaction baseline
global.gc();
const before = v8.getHeapStatistics(); // capture stats after compaction

// --- run your workload here ---

// Compact again; compare available space
global.gc();
const after = v8.getHeapStatistics();

const deltaMB = ((after.total_available_size - before.total_available_size) / 1024 / 1024).toFixed(2);
console.log(`Available space delta after workload: ${deltaMB} MB`);
// A large negative delta means the workload consumed available space without returning it — fragmentation risk.
// A near-zero delta is healthy.

Verification checkpoint: If the delta exceeds –15 % of before.total_available_size, the workload is generating fragmentation faster than V8’s deferred compaction reclaims it.

Runnable Code Reference

Fragmentation-prone pattern: random-size allocations with staggered deletions

// Anti-pattern: random-size Buffer allocations → staggered deletions → Old Space holes
const cache = new Map();

for (let i = 0; i < 100_000; i++) {
  // Math.random() produces variable sizes — every allocation lands in a different free-list bucket
  const payload = Buffer.alloc(Math.floor(Math.random() * 5_000) + 100);
  cache.set(i, payload);
}

// Deleting every other entry leaves alternating live/dead blocks — the classic fragmentation pattern
for (let i = 0; i < 100_000; i += 2) {
  cache.delete(i);
}
// Result: ~38 % old_space fragmentation; Mark-Compact pauses of 110–160 ms

Fixed pattern: pre-allocated TypedArray slab

// Fix: serve fixed-size views from a pre-allocated contiguous slab
// No individual heap allocations; no fragmentation from variable sizes

const POOL_SIZE = 1024 * 1024; // 1 MB slab
const pool = Buffer.allocUnsafe(POOL_SIZE); // allocate once — goes to old_space immediately
let offset = 0;

function allocateChunk(size) {
  if (offset + size > POOL_SIZE) {
    offset = 0; // ring-buffer reset — caller must ensure prior chunks are no longer needed
  }
  // subarray returns a view into the same underlying ArrayBuffer — no heap allocation
  const chunk = pool.subarray(offset, offset + size);
  offset += size;
  return chunk;
}

// Result: old_space fragmentation drops from ~38 % to < 8 %;
// Mark-Compact pauses fall to 25–40 ms

Verification & Regression Prevention

Confirm the fix worked. After applying slab pooling or reducing variable-size allocation churn, re-run the Step 2 measurement script. Target metrics for a healthy workload:

  • old_space fragmentation ratio below 0.15
  • large_object_space fragmentation ratio below 0.20
  • Mark-Compact pauses below 50 ms under steady load (visible in --trace-gc output)

Guard against recurrence. Add a lightweight monitoring threshold to your application’s health-check or CI load-test step:

// Use-case: CI / health-check assertion — fail if old_space fragmentation exceeds threshold
const v8 = require('v8');

function assertFragmentationHealthy(threshold = 0.20) {
  const stats = v8.getHeapSpaceStatistics();
  const old = stats.find(s => s.space_name === 'old_space');

  if (!old || old.physical_space_size === 0) return; // space not yet allocated

  const ratio = old.space_available_size / old.physical_space_size;

  if (ratio > threshold) {
    // Throw so CI marks the step as failed
    throw new Error(
      `old_space fragmentation ratio ${ratio.toFixed(3)} exceeds threshold ${threshold}. ` +
      `available=${(old.space_available_size / 1024 / 1024).toFixed(1)} MB, ` +
      `physical=${(old.physical_space_size / 1024 / 1024).toFixed(1)} MB`
    );
  }

  console.log(`old_space fragmentation OK: ratio=${ratio.toFixed(3)}`);
}

assertFragmentationHealthy();

Additional guardrails:

  • Avoid --max-old-space-size as a first response. Raising the heap limit defers compaction thresholds but does nothing about fragmentation — the enlarged space fills with the same holes at a larger scale, and eventual OOM crashes become harder to debug.
  • Watch large_object_space separately. The V8 heap layout documentation explains why LOS is never compacted; if your workload allocates > 1 MB buffers repeatedly, a purpose-built pool (e.g. an ArrayBuffer slab managed with DataView offsets) is the only durable fix.
  • Correlate with allocation timelines. Use the allocation timeline tool to surface which constructors are responsible for the highest byte-rate in Old Space between compaction events.

FAQ

Does V8 automatically defragment the heap?

Yes, but only during the Mark-Compact phase of a major GC cycle. V8’s incremental and concurrent GC (Orinoco) deliberately defers compaction to keep individual pause times low. Fragmentation accumulates until it crosses an internal threshold — roughly 30–40 % of Old Space — or until memory pressure forces a full cycle. You can observe this deferral in --trace-gc output as long stretches of Scavenge-only events followed by a single large Mark-Compact pause.

How do I distinguish heap fragmentation from a memory leak in production?

Watch v8.getHeapStatistics() over time. Fragmentation shows total_heap_size growing while used_heap_size stays roughly flat and total_available_size climbs — the heap is large but the bytes are in scattered small blocks. A genuine memory leak shows used_heap_size increasing continuously without recovery after GC, and total_available_size shrinking toward zero. If a forced global.gc() call (with --expose-gc) restores significant available space, you are dealing with fragmentation, not a leak.

Which V8 flags help control fragmentation?

--max-semi-space-size=<MB> controls the Young Generation size, which affects how frequently objects are promoted to Old Space. --optimize-for-size instructs V8 to prefer compaction over throughput — useful in memory-constrained environments. --trace-gc is essential for observing when Mark-Compact cycles actually occur in staging. Avoid --expose-gc in production; it is a diagnostic tool only.