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.
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_spacefragmentation ratio below 0.15large_object_spacefragmentation ratio below 0.20- Mark-Compact pauses below 50 ms under steady load (visible in
--trace-gcoutput)
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-sizeas 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_spaceseparately. 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. anArrayBufferslab managed withDataViewoffsets) 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.
Related
- How mark-and-sweep garbage collection works — parent cluster: the GC algorithm that drives compaction timing
- JavaScript Memory Fundamentals & Runtime Mechanics — grandparent pillar: V8 heap segmentation, allocation lifecycle, and pointer tracking
- Understanding the V8 heap layout and memory segments — deep dive into New Space, Old Space, and large-object space boundaries
- Interpreting heap snapshots for memory analysis — use heap snapshots to identify which objects are driving Old Space churn