Understanding the V8 Heap Layout and Memory Segments
V8 manages JavaScript memory through a segmented, generational heap designed for low-latency garbage collection and high allocation throughput. This reference is aimed at frontend and Node.js engineers who need to diagnose heap growth, tune GC thresholds, and read profiler output accurately. It is part of the JavaScript Memory Fundamentals & Runtime Mechanics section; for the specific case where a Node.js process runs out of heap entirely, see Why does my Node.js process hit the heap limit and how to fix it.
Understanding which space holds which objects — and why — is the prerequisite for reading any DevTools memory profile or v8.getHeapSpaceStatistics() output correctly.
Conceptual Grounding: The Five Heap Spaces
V8 does not manage memory as a single flat pool. It divides the heap into five named spaces, each with a different allocation strategy, collection algorithm, and growth behaviour. Knowing which space is growing tells you immediately which class of problem you are facing.
New Space (Young Generation)
New Space is split into two equal semi-spaces: From-space (active) and To-space (evacuation target). Every new object enters From-space via a cheap bump-pointer allocation — V8 simply advances a pointer, so allocation cost is O(1) with no free-list lookup. When From-space fills up, V8 runs a Scavenge: live objects are copied to To-space (or promoted if they have survived before), the roles of the two semi-spaces are swapped, and the old From-space is wiped. Scavenge pauses are typically under 1 ms because only live objects are touched.
The default size is controlled by --max-semi-space-size (in MB, per semi-space). Increasing it reduces minor GC frequency at the cost of higher per-GC copy work.
Old Space (Tenured Generation)
Objects that survive two Scavenge cycles are promoted to Old Space. This space uses a concurrent mark-sweep-compact algorithm: a background thread marks live objects while the main thread keeps running, then a brief stop-the-world pause performs sweeping and optional compaction to reduce fragmentation. Major GC pauses can range from a few milliseconds to tens of milliseconds depending on heap size and live set. The mark-and-sweep algorithm used here is the subject of a dedicated explainer.
Old Space is capped by --max-old-space-size (default ~1.5 GB on 64-bit systems with ample RAM). When it cannot grow further, V8 performs an emergency full GC; if that still cannot reclaim enough space, the process exits with FATAL ERROR: Reached heap limit.
Map Space
Map Space stores V8’s hidden classes, also called Maps or shapes. Each hidden class occupies a fixed 256-byte slot. Map Space is never compacted — slots are reused but not relocated — so its address space grows monotonically. Excessive hidden class proliferation (caused by inconsistent property insertion order or heavy use of delete) inflates Map Space and degrades property access performance by defeating inline caches. See the section on stack vs heap memory allocation for how primitive values on the stack avoid this cost entirely.
Code Space
Code Space holds machine code generated by V8’s JIT compilers (Sparkplug, Maglev, TurboFan). Pages in Code Space are marked executable. When a function is deoptimised — because its type feedback proved incorrect — its compiled code is discarded and the slot is reclaimed. Code Space grows during warm-up as functions are compiled, then stabilises.
Large Object Space
Any single allocation above approximately 512 KB bypasses both semi-spaces and lands directly in Large Object Space. Objects here are never copied or compacted — they are collected in-place during major GC. Common residents: large ArrayBuffer instances, long strings, and big TypedArray views. Because these objects are never moved, fragmentation accumulates if they are frequently created and released; the virtual address space can grow even when used_heap_size falls.
Diagnostic Workflow
Follow these steps in order to identify which heap space is causing production pressure.
Step 1 — Capture per-space statistics with v8.getHeapSpaceStatistics()
Action: Call the API in Node.js to get a baseline per-space breakdown.
const v8 = require('v8');
// Returns one entry per space; sizes are in bytes
const spaces = v8.getHeapSpaceStatistics();
spaces.forEach(({ space_name, space_size, space_used_size, space_available_size }) => {
const usedMB = (space_used_size / 1024 / 1024).toFixed(2);
const sizeMB = (space_size / 1024 / 1024).toFixed(2);
const pct = ((space_used_size / space_size) * 100).toFixed(1);
console.log(`${space_name.padEnd(24)} used=${usedMB} MB / allocated=${sizeMB} MB (${pct}%)`);
});
Expected output on a healthy Node.js 20 server:
new_space used=0.84 MB / allocated=2.00 MB (42.0%)
old_space used=4.21 MB / allocated=6.00 MB (70.2%)
code_space used=0.78 MB / allocated=1.00 MB (78.0%)
map_space used=0.30 MB / allocated=0.50 MB (60.0%)
large_object_space used=0.00 MB / allocated=0.00 MB (0.0%)
If old_space used exceeds 80% of allocated and continues climbing across requests, investigate retention. If large_object_space allocated grows without dropping, look for ArrayBuffer leaks.
Step 2 — Attach DevTools and capture a heap snapshot
CLI flag: node --inspect-brk --expose-gc server.js
DevTools path: open chrome://inspect, click the target, navigate to DevTools → Memory → Heap Snapshot. Call global.gc() in the console (requires --expose-gc) to clear transient allocations, then click Take snapshot. This is Snapshot 1 (baseline).
Expected metric: record Total size shown in the snapshot pane (e.g. 14.2 MB).
Step 3 — Reproduce the allocation event
Action: exercise the suspected route, component, or batch job while the snapshot panel is open. Capture Snapshot 2 immediately after.
DevTools path: DevTools → Memory → Heap Snapshot → Comparison view (select Snapshot 2, then choose “Comparison” in the dropdown above the list). Sort by #Delta (object count increase) descending.
Expected metric: identify the constructor types with the highest #New count. Constructors that should not persist (e.g., request/response objects, React synthetic events) but appear with a high #Retained count indicate a retention problem.
Step 4 — Trace the retention chain
DevTools path: click any retained object in the Comparison view → expand the Retainers pane at the bottom. V8 shows the shortest path from GC roots to the object. Common roots that cause Old Space bloat: global properties, module-level caches, class static fields, and setTimeout/setInterval callbacks.
Expected metric: the retention chain length should be 2–4 hops for legitimate caches. Chains longer than 6 hops through anonymous closures indicate accidental retention.
Step 5 — Verify reclamation
Action: call global.gc() three times (to ensure incremental marking completes), then capture Snapshot 3.
Expected metric: used_heap_size in Snapshot 3 should be within ±5% of Snapshot 1. If it is more than 10% higher, the delta represents genuinely retained (leaked) memory. Use the Summary view grouped by constructor to confirm object counts decreased proportionally.
Code Patterns and Signatures
Pattern 1 — Sampling heap statistics on a fixed interval for CI/CD monitoring
Use this to detect gradual Old Space growth in long-running processes before it becomes an OOM crash.
const v8 = require('v8');
// Poll heap statistics every 30 seconds; emit a warning if Old Space exceeds threshold
const OLD_SPACE_WARNING_MB = 400; // adjust to your --max-old-space-size budget
function monitorOldSpace() {
const spaces = v8.getHeapSpaceStatistics();
const oldSpace = spaces.find(s => s.space_name === 'old_space');
if (!oldSpace) return;
const usedMB = oldSpace.space_used_size / 1024 / 1024;
if (usedMB > OLD_SPACE_WARNING_MB) {
// Emit a structured log entry so your APM tool can alert on it
process.stderr.write(JSON.stringify({
level: 'warn',
event: 'old_space_threshold_exceeded',
used_mb: usedMB.toFixed(2),
threshold_mb: OLD_SPACE_WARNING_MB,
ts: new Date().toISOString(),
}) + '\n');
}
}
setInterval(monitorOldSpace, 30_000).unref(); // .unref() so the timer does not block process exit
Pattern 2 — Simulating generational promotion to validate GC tuning
Use this to confirm that objects with short intended lifetimes do not accidentally escape to Old Space.
// Run with: node --expose-gc --max-semi-space-size=8 promote-test.js
function simulateGenerationalPressure() {
// Phase 1: flood New Space with short-lived objects (should be Scavenged away)
for (let i = 0; i < 50_000; i++) {
const ephemeral = { id: i, value: Math.random() }; // intentionally short-lived
void ephemeral; // prevent optimisation elision
}
// Phase 2: create objects that are explicitly retained (should be promoted)
if (!global._longLivedCache) global._longLivedCache = [];
global._longLivedCache.push({ createdAt: Date.now(), tag: 'retained' });
}
// Trigger two Scavenge cycles to force promotion of phase-2 objects
simulateGenerationalPressure();
global.gc(); // first minor GC — objects from phase 2 survive once
simulateGenerationalPressure();
global.gc(); // second minor GC — phase-2 objects promoted to Old Space
// Inspect the result
const stats = require('v8').getHeapSpaceStatistics();
const old = stats.find(s => s.space_name === 'old_space');
console.log(`Old Space after promotion: ${(old.space_used_size / 1024 / 1024).toFixed(2)} MB`);
Pattern 3 — Detecting Map Space inflation from hidden class proliferation
Use this when object property access performance degrades and you suspect shape instability.
// Anti-pattern: dynamic property addition causes a new hidden class per unique shape
function buildObjectDynamic(keys) {
const obj = {};
keys.forEach(k => { obj[k] = true; }); // each unique key order creates a new Map entry
return obj;
}
// Fix: declare all properties upfront in a consistent order
function buildObjectStable(keys) {
// Pre-declare every key so V8 can create one hidden class for all instances
const template = { a: undefined, b: undefined, c: undefined };
keys.forEach(k => { template[k] = true; });
return Object.assign({}, template); // clone to avoid mutating the template
}
// Measure: call v8.getHeapSpaceStatistics() before and after creating 10,000 objects
// with each approach and compare map_space.space_used_size
Pattern 4 — Capturing a heap snapshot programmatically (Node.js 11.13+)
Use this in CI to save a heap snapshot on OOM signal for offline analysis.
const v8 = require('v8');
const path = require('path');
// Write a heap snapshot to disk and return the file path
function captureHeapSnapshot(tag = 'snapshot') {
const filename = path.join(
process.cwd(),
`heap-${tag}-${Date.now()}.heapsnapshot`
);
// writeHeapSnapshot is synchronous — use only on signal handlers or test teardown
v8.writeHeapSnapshot(filename);
return filename;
}
// Capture on SIGUSR2 so you can trigger it from the shell without restarting the process
process.on('SIGUSR2', () => {
const file = captureHeapSnapshot('sigusr2');
console.error(`Heap snapshot written: ${file}`);
});
Symptom-to-Fix Reference Table
| Symptom | Root Cause | Immediate Action | Measurable Impact |
|---|---|---|---|
old_space used grows by 5–10 MB per request and never falls |
Module-level cache or global array accumulates request objects without eviction | Open DevTools → Memory → Heap Snapshot → Comparison view, sort by #Delta, find the constructor leaking instances; add an eviction policy or convert to WeakMap |
old_space.space_used_size stabilises within 2–3 request batches |
FATAL ERROR: Reached heap limit Allocation failed on high traffic |
Old Space capped by --max-old-space-size; live set exceeds the limit |
Raise --max-old-space-size as an immediate mitigation; then use DevTools → Memory → Heap Snapshot → Retainers to find the true retention source and fix it |
Process no longer crashes; Old Space growth trend flattens after fix |
map_space.space_used_size climbs steadily over hours |
Hidden class proliferation from inconsistent property insertion order or heavy delete use |
Grep for dynamic property assignment patterns; refactor to declare object shape once at construction; see what causes memory fragmentation in the V8 engine | map_space growth rate drops; property access throughput improves 5–20% in V8-native benchmarks |
large_object_space allocated grows even when used_heap_size falls |
Fragmentation: large freed allocations leave holes that V8 cannot compact | Pool large ArrayBuffer instances and reuse them instead of creating/releasing repeatedly; consider using SharedArrayBuffer with explicit lifecycle management |
large_object_space virtual address growth flattens; process RSS decreases over time |
| Major GC pause spikes above 100 ms in production | Old Space too large relative to available CPU budget for concurrent marking; or very high allocation rate outpacing background marking | Reduce --max-old-space-size to force more frequent but smaller collections; tune --gc-interval; split work into worker threads to isolate GC pressure |
P99 GC pause duration drops; measure via --trace-gc output or Node.js perf_hooks GC performance entries |
process.memoryUsage().rss far exceeds heapTotal |
external memory: ArrayBuffer backing stores, native addons, or WebAssembly memory growing outside V8’s heap accounting |
Check process.memoryUsage().external; audit native addon cleanup; transfer ArrayBuffer ownership to worker threads to isolate accounting |
rss - heapTotal gap narrows after fixing external allocations |
Heap snapshot shows many (closure) entries with large retained size |
Closures in long-lived callbacks or event handlers capture large outer scopes accidentally | Use DevTools → Memory → Heap Snapshot → Summary view, filter by (closure), expand Retainers to find the capturing function; refactor to pass only the data the callback needs |
Closure retained size drops; confirmed by re-snapshotting after fix |
| Code Space grows during warm-up but never releases | JIT-compiled code retained for all reachable functions; no deoptimisation triggered | Normal behaviour — Code Space stabilises after the first few requests as all hot functions are compiled; if it grows indefinitely, look for dynamic Function() or eval() calls generating new code objects at runtime |
code_space.space_used_size plateau visible in v8.getHeapSpaceStatistics() after initial warm-up |
Edge Cases and Gotchas
V8 Lazy GC Masks True Old Space Growth
V8 does not always trigger a major GC immediately when Old Space usage increases. Background incremental marking can take seconds to complete, meaning v8.getHeapSpaceStatistics() sampled immediately after a load spike may show lower usage than the actual live set. Always call global.gc() (with --expose-gc) three times before capturing a diagnostic snapshot to force completion of all pending marking and sweeping work.
process.memoryUsage().heapUsed Aggregates All Spaces
heapUsed is the sum of space_used_size across all spaces. A large ArrayBuffer in Large Object Space contributes to heapUsed but appears separately in external memory within process.memoryUsage(). Relying on heapUsed alone hides which space is actually growing. Always cross-reference with v8.getHeapSpaceStatistics() to get per-space granularity.
Pointer Compression Changes Retained-Size Calculations
V8 enables pointer compression on 64-bit platforms (since Node.js 14 / Chrome 80). Compressed pointers are 4 bytes instead of 8 bytes, so retained-size numbers in DevTools heap snapshots are lower than you might expect from first principles. Do not compare retained sizes between a compressed-pointer build and a non-compressed build — the numbers are not comparable. Always note the Node.js / Chrome version when recording baselines.
Extension Contexts Inflate Browser Heap Measurements
When profiling in Chrome with browser extensions installed, extension background service workers share the same DevTools session heap measurement. An extension that caches DOM trees can add tens of megabytes to the reported heap that are not your application’s responsibility. Always profile in a clean Chrome profile (no extensions) or use node --inspect for pure Node.js workloads to avoid this pollution. See how to visualize V8 memory allocation in Chrome DevTools for clean profiling setup steps.
Scavenge Frequency Masks New Space Allocation Bugs
Because Scavenge runs frequently and cheaply, high-frequency allocation of short-lived objects may never surface in a heap snapshot — the snapshot is taken between collections when the live set is small. Use the allocation timeline (DevTools → Memory → Allocation instrumentation on timeline) instead: it records every allocation event and lets you filter by time window, revealing objects created during a specific operation even if they were subsequently collected.
delete obj.prop Does Not Free Memory Immediately
Calling delete on a property removes it from the object but forces a hidden class transition. V8 generates a new Map entry for the post-delete shape, increasing Map Space usage. The original property value is not freed until the next GC cycle scans the object. In hot paths, avoid delete; instead set the property to null or undefined to signal “no value” without causing a shape transition.
FAQ
What is the difference between V8 New Space and Old Space?
New Space is optimised for short-lived objects using a fast semi-space copying collector (Scavenge). Objects that survive two minor GC cycles are promoted to Old Space, which uses a concurrent mark-sweep-compact algorithm. New Space defaults to 1–16 MB per semi-space; Old Space can grow to several gigabytes. The key practical difference is that Scavenge pauses are typically under 1 ms while major GC pauses can reach 20–100 ms on a large heap.
How do I see per-space heap breakdown in Node.js without DevTools?
Call v8.getHeapSpaceStatistics(). It returns an array of objects — one per space — each with space_name, space_size (bytes committed), space_used_size (bytes actively used by live objects), space_available_size (bytes available before the space must grow), and physical_space_size. All values are in bytes; divide by 1024 * 1024 for MB. This API is available in all current Node.js LTS versions with no flags required.
Why does my Old Space keep growing even after a garbage collection event?
Objects in Old Space are only collected during a major GC, which runs less frequently than Scavenge. If live references hold them — through module-level caches, static class fields, event listeners, or closures that capture large outer scopes — the collector cannot reclaim them regardless of how many GC cycles run. Use DevTools → Memory → Heap Snapshot → Comparison view, sort by #Delta, select a retained object, and examine the Retainers pane to find the reference chain keeping it alive. The mark-and-sweep algorithm page explains precisely how reachability is determined.
What kinds of objects land in Large Object Space?
Any single allocation above approximately 512 KB bypasses New Space and Old Space and is placed directly in Large Object Space. Common examples: new ArrayBuffer(1_000_000), long concatenated strings, large TypedArray views (Float64Array, Int32Array), and Buffer.allocUnsafe(n) in Node.js for large n. These objects are never moved or compacted, so fragmentation can accumulate in virtual address space even as used_heap_size falls. Pool or reuse them to avoid fragmentation.
Related
- JavaScript Memory Fundamentals & Runtime Mechanics — parent section covering the full runtime memory model
- How Mark-and-Sweep Garbage Collection Works — the major GC algorithm that collects Old Space
- Stack vs Heap Memory Allocation in JavaScript — how primitives avoid heap allocation entirely
- Why Does My Node.js Process Hit the Heap Limit and How to Fix It — focused page on OOM diagnosis and
--max-old-space-sizetuning - How to Visualize V8 Memory Allocation in Chrome DevTools — step-by-step DevTools profiling setup for this section