Reading V8 Heap Space Statistics in Node.js

process.memoryUsage().heapUsed says your service uses 700 MB, and you need to know whether that is a leak in old space, a burst of large objects, fragmentation, or growing code. This guide from Understanding the V8 Heap Layout and Memory Segments, part of JavaScript Memory Fundamentals & Runtime Mechanics, explains every field returned by v8.getHeapSpaceStatistics() and v8.getHeapStatistics(), and which patterns in them point to which problem.

Symptom Root Cause Immediate Action Measurable Impact
old_space used grows steadily Retained objects accumulating — a leak or unbounded cache Take heap snapshots and diff Confirms leak and identifies its owner
old_space size ≫ used after load drops Fragmentation or lazy shrinking after a spike Watch whether size shrinks over the next major GCs Distinguishes fragmentation from retention
large_object_space spikes during jobs Big arrays, strings or tables built in one go Stream or chunk the job Flatter heap during batch work
new_space constantly near full with high GC count High allocation rate (churn) Profile allocation; consider --max-semi-space-size Fewer scavenges, lower CPU
total_heap_size near heap_size_limit Approaching OOM Alert and capture a snapshot before the crash Evidence captured instead of lost

Root Cause: One Number Hides Several Spaces

heapUsed is the sum of objects in every V8 space. Those spaces have very different jobs, and trouble in each one has a different cause:

  • new_space — the young generation. New objects are bump-allocated here and collected by the scavenger. Its size is small (a few to tens of MB) and it fills and empties constantly; high used values are normal between scavenges.
  • old_space — objects that survived scavenges. This is where leaks and caches live, and where major mark-sweep-compact GC works.
  • code_space — machine code for compiled functions (bytecode lives in old space). It grows with the amount of code compiled; see code space and bytecode flushing.
  • map_space in older versions — hidden classes (maps). In newer V8 versions maps are allocated in old space and this entry may be absent.
  • shared_space, trusted_space and similar — newer V8 versions add spaces for shared-heap objects and security-sensitive metadata; they are usually small.
  • new_large_object_space, large_object_space, code_large_object_space — objects above the regular size limit, each on its own pages, as described in large object space.
  • read_only_space — immutable built-in objects shared by isolates; constant.

For each space, space_size is how much memory the space currently has committed (its pages), space_used_size is how much of that is occupied by objects, space_available_size is how much more can be allocated without growing, and physical_space_size is how much is actually backed by physical memory. v8.getHeapStatistics() adds totals and the crucial heap_size_limit — the maximum heap the isolate will grow to — plus external_memory and, in recent Node versions, counts of native and detached contexts. The gap between total_heap_size and used_heap_size is committed-but-unused memory: free lists inside pages, recently emptied pages not yet released, or fragmentation.

What each space's numbers tell you For each space a bar shows used size inside committed size. New space: 12 of 16 megabytes, constant churn is normal. Old space: 520 of 610 megabytes, steady growth here means retention. Code space: 18 of 20 megabytes, growth means new code. Large object space: 140 of 140 megabytes, spikes mean big arrays or strings. The gap between used and committed indicates free or fragmented memory. Space used / committed Growth here usually means new_space 12 / 16 MB normal churn old_space 520 / 610 MB retention: leaks, caches code_space 18 / 20 MB new code (eval, templates) large_object_space 140 / 140 MB big arrays, strings, tables committed but free (gap = free lists, fragmentation)

Step-by-Step Fix

  1. Log per-space statistics periodically. Sample v8.getHeapSpaceStatistics() every 10–60 seconds and record space_used_size and space_size per space, plus heap_size_limit from v8.getHeapStatistics(). Verification: you have a time series per space, not just heapUsed.
  2. Find the growing space. Plot each space’s used size under steady load. Verification: you can name the space whose used size trends upward.
  3. Old space growing: treat as retention. Capture two heap snapshots some minutes apart (see taking heap snapshots from a live Node.js process) and diff them. Verification: the growing constructors and their retainers are identified.
  4. Committed ≫ used: check fragmentation. If space_size stays far above space_used_size long after a load spike, look at allocation patterns that interleave long- and short-lived objects. Verification: the gap shrinks over subsequent major GCs, or persists and needs fragmentation fixes.
  5. Large or code spaces growing: find the producer. For large object space, sort a snapshot by shallow size; for code space, search for dynamic compilation. Verification: the producer is identified and bounded.
  6. Alert on headroom. Alert when used_heap_size / heap_size_limit exceeds a threshold such as 0.85 for several minutes. Verification: you get warning and time to capture evidence before an out-of-memory crash.
Per-space trend reveals where growth lives Over three hours of steady traffic, new space oscillates around 10 megabytes, code space stays around 18, large object space stays around 60, and old space climbs from 180 to 520 megabytes. The total heapUsed would show growth, but only the per-space view shows it is old space retention rather than large objects or code. 550 MB 0 hours of steady traffic (0 → 3) old_space used: 180 → 520 MB large_object_space: flat ~60 MB code + new space: flat

Command and Code Reference

Use case: a compact per-space sampler that emits one line of JSON. Easy to ship to logs or a metrics pipeline.

// heap-spaces-sampler.js
const v8 = require('node:v8');
const MB = 1048576;

function sampleSpaces() {
  const out = { t: Date.now() };
  for (const s of v8.getHeapSpaceStatistics()) {
    out[s.space_name] = {
      used: +(s.space_used_size / MB).toFixed(1),      // live objects (plus garbage)
      committed: +(s.space_size / MB).toFixed(1),      // pages the space holds
    };
  }
  const h = v8.getHeapStatistics();
  out.limit = +(h.heap_size_limit / MB).toFixed(0);    // OOM ceiling for this isolate
  out.headroom = +(1 - h.used_heap_size / h.heap_size_limit).toFixed(3);
  out.external = +(h.external_memory / MB).toFixed(1); // ArrayBuffers, native wrappers
  return out;
}

setInterval(() => console.log(JSON.stringify(sampleSpaces())), 30_000).unref();

Use case: a quick one-off check from the command line.

# Print a table of spaces for a short script or a REPL session
node -e "console.table(require('v8').getHeapSpaceStatistics().map(s => ({ space: s.space_name, usedMB: (s.space_used_size/1048576).toFixed(1), sizeMB: (s.space_size/1048576).toFixed(1) })))"

Verification and Regression Prevention

You are reading the statistics correctly when your diagnosis predicts what a snapshot will show: if old space grew, a snapshot diff reveals growing constructors; if large object space grew, the largest shallow-size objects explain it; if committed exceeds used but snapshots are stable, you are looking at free space rather than retention. Confirm each hypothesis before acting on it.

Export per-space used and committed sizes as metrics rather than only heapUsed, and build dashboards that stack them. Alert on old-space growth slope and on headroom against heap_size_limit. The same data feeds the approach in alerting on memory leaks with growth slope, and it makes post-incident analysis far faster than a single heap number.

Predicting what a snapshot will show Use the space statistics to predict the snapshot. If old space grew, a snapshot diff should reveal growing constructors. If large object space grew, the largest shallow-size objects should explain it. If committed exceeds used but snapshots are stable, you are looking at free space rather than retention. Which statistic changed? Snapshot diff shows growing constructors old_space used grew Largest shallow-size objects explain it large_object_space grew Free space, not retention; no leak committed > used, stable

Edge Cases and Gotchas

Space names change between V8 versions

Spaces are added, renamed and removed as V8 evolves (map_space disappeared in newer versions; shared_space and trusted_space appeared). Key dashboards by whatever names the running version reports and tolerate missing entries.

Used includes garbage

space_used_size counts allocated objects, including unreachable ones not yet collected. Sample regularly and look at the troughs, or force a GC in test environments, before concluding that a space is retaining memory.

Heap stats ignore native memory

Buffers’ contents, native addons and the allocator’s own overhead are outside these numbers. If RSS grows while all spaces are flat, look at external and native memory as in reading RSS vs heapUsed in production Node.js.

Worker threads have their own statistics

Each worker’s isolate has separate spaces. Calling the APIs in the main thread reports the main isolate only; sample inside each worker (or use worker.getHeapSnapshot() for snapshots) to see worker memory.

Frequently Asked Questions

What is the difference between space_size and space_used_size?

space_size is how much memory the space has committed — the pages it currently owns. space_used_size is how much of that is occupied by objects. The difference is free space inside those pages, which may be reused for new allocations or released later.

Which space should I watch for leaks?

Old space. Leaked objects survive scavenges and are promoted there, so a steady rise in old-space used size under constant load is the classic leak signature. Large object space can also grow from leaked big arrays or strings, so watch it too.

What does heap_size_limit mean?

It is the maximum size the V8 heap will grow to before V8 reports a fatal out-of-memory error. It is set by default from system memory and can be changed with --max-old-space-size (plus the young generation size). It does not include external memory.

How often should I sample these statistics?

Every 10 to 60 seconds is plenty for trend analysis. The calls are cheap — they read counters V8 already maintains — but there is no benefit in sampling faster than garbage collection cycles change the picture. For incident analysis, increase the frequency temporarily and include a timestamp so you can line samples up with GC traces and request logs.

Do these numbers match what a heap snapshot shows?

Not exactly. A heap snapshot forces a full garbage collection first and then records only reachable objects, so its total is usually lower than used_heap_size sampled a moment earlier. Use the statistics for continuous trends and the snapshot for identifying which objects make up the retained part.

Why is total_heap_size much larger than used_heap_size?

Committed pages include free space: memory freed by recent collections that V8 has not yet returned, free lists inside partially used pages, and fragmentation. A large, persistent gap after load drops suggests fragmentation; a gap that shrinks over time is normal lazy release.