Large Object Space and When Objects Skip New Space

v8.getHeapSpaceStatistics() shows large_object_space at 400 MB while the rest of the old generation is modest, or heap growth jumps in big steps whenever a report is generated. This guide from Understanding the V8 Heap Layout and Memory Segments, in JavaScript Memory Fundamentals & Runtime Mechanics, explains which objects V8 allocates in large object space, why they behave differently from ordinary objects, and how to keep them from dominating memory.

Symptom Root Cause Immediate Action Measurable Impact
large_object_space holds hundreds of MB Big arrays, strings or backing stores above the regular object size limit Find the large objects in a snapshot sorted by shallow size Identifies the few objects responsible
Heap jumps in large steps during batch jobs Each big array or string allocates dedicated pages Stream or chunk data instead of building one huge array Smaller, steadier growth
RSS stays high after large objects are freed Freed large-object pages returned lazily; fragmentation elsewhere Measure after GC; avoid repeated huge allocate/free cycles RSS tracks live data more closely
Major GC pauses correlate with big arrays Large arrays of pointers must be scanned fully Use typed arrays for numeric data Less marking work per GC
Growing an array repeatedly spikes memory Each growth copies into a new, larger backing store Preallocate with known size or use chunked storage No temporary double allocation

Root Cause: Big Objects Get Their Own Pages

V8 organises its heap into spaces made of pages. Ordinary objects are allocated in regular pages: young objects in the new space (collected by the scavenger, which copies survivors), promoted objects in the old space (collected by mark-sweep-compact, which may move objects to defragment). Copying and moving are cheap for small objects and expensive for big ones — copying a 20 MB array to promote it would be wasteful.

So V8 treats objects above a size threshold — the maximum regular object size, on the order of 128 KB on 64-bit builds — differently. They are allocated directly in a large object space, each on its own dedicated page (or run of pages) sized to fit. There is a young variant, new_large_object_space, for freshly allocated large objects, and an old variant, large_object_space, where they end up if they survive; promotion happens by re-labelling the page rather than copying the bytes. Code objects above the threshold have their own code_large_object_space. Large objects are never moved by compaction.

What typically lands there? The backing store of any array with more than roughly 16,000 elements (each element is at least a pointer-sized slot), long strings (a 200 KB JSON response as a string), big Map/Set hash tables, and large property dictionaries. Note that ArrayBuffer contents are not on the V8 heap at all — they are external memory, as explained in ArrayBuffer and Blob memory outside the JS heap — only their small wrapper objects are.

The consequences are practical. Each large object costs at least one page of memory with its own bookkeeping, and freeing it returns whole pages. Growing an array by pushing past its capacity allocates a new, larger backing store and copies the old contents, so at the moment of growth both stores exist — for a 50 MB array that briefly means 100 MB or more. And a large array of object pointers must be fully scanned by the marker on every major GC, so keeping millions of small objects in one giant array makes major GC marking proportionally slower.

Regular spaces versus large object spaces Small objects are allocated in new space, and survivors are copied into old space where compaction may move them. Objects larger than the regular object size limit, about 128 kilobytes, are allocated on dedicated pages in new large object space; if they survive, their pages are relabelled as large object space without copying. Large objects are never moved by compaction. Array backing stores over about 16 thousand elements, long strings and big hash tables are typical examples. Objects below ~128 KB new space bump allocation, scavenged old space may be compacted (moved) copy Objects above ~128 KB new large object space one dedicated page each large object space never moved relabel Typical residents arrays > ~16k slots long strings big Map/Set tables huge dictionaries

Step-by-Step Fix

  1. Measure the spaces. In Node.js, log v8.getHeapSpaceStatistics() periodically and watch new_large_object_space and large_object_space (space_used_size). In the browser, use a heap snapshot. Verification: you know how much of the heap is large objects and whether it grows.
  2. Find the large objects. Take a heap snapshot and sort the Summary view by Shallow Size. Large objects appear at the top as individual (array), (string) or backing-store entries with shallow sizes in the hundreds of KB or more. Verification: you have a list of the biggest individual objects and their retainers.
  3. Classify each large object. Decide whether it is necessary (a working buffer), accidental (a cached raw response string), or a symptom of growth (an ever-growing array). Verification: each large object has a reason to exist or is marked for removal.
  4. Replace growing arrays with preallocated or chunked storage. If the final size is known, allocate once (new Array(n) for generic data, a typed array for numbers); if not, store chunks of fixed size in a small array of arrays. Verification: growth no longer produces temporary double allocations in the heap timeline.
  5. Move numeric bulk data to typed arrays. Arrays of numbers stored as generic arrays keep pointer-sized slots and, for non-integer values, can box numbers as separate objects. Verification: after conversion, the data lives in external ArrayBuffer memory and major GC marking time falls.
  6. Drop large strings after parsing. Parse responses with response.json() or process text streams instead of keeping the raw text. Verification: large (string) entries disappear from the snapshot.
Growth by push versus preallocation Pushing ten million numbers into a generic array causes the backing store to be reallocated several times; at each growth step the old and new stores briefly coexist, producing spikes up to about 1.5 times the final size. A preallocated Float64Array of ten million elements allocates 80 megabytes once, outside the V8 heap, with no spikes. 180 MB 0 elements pushed (0 → 10 million) generic array + push: copy spikes at each growth preallocated Float64Array: 80 MB once, off-heap

Command and Code Reference

Use case: watch large object spaces in a Node.js process.

// heap-spaces.js — log the spaces that matter every 10 seconds
const v8 = require('node:v8');
const mb = (n) => (n / 1048576).toFixed(1);

setInterval(() => {
  const spaces = Object.fromEntries(
    v8.getHeapSpaceStatistics().map((s) => [s.space_name, s.space_used_size]),
  );
  console.log(
    `new ${mb(spaces.new_space)} | old ${mb(spaces.old_space)} | ` +
    `new_lo ${mb(spaces.new_large_object_space)} | lo ${mb(spaces.large_object_space)} MB`,
  );
}, 10_000).unref(); // do not keep the process alive just for logging

Use case: replace an unbounded push-built array with chunked storage. Fixed-size chunks never trigger a huge reallocation, and dropping old chunks frees memory in page-sized steps.

// Chunked append-only store: each chunk is a fixed-size typed array
class ChunkedFloats {
  constructor(chunkSize = 65536) {        // 64k × 8 bytes = 512 KB per chunk
    this.chunkSize = chunkSize;
    this.chunks = [];
    this.length = 0;
  }
  push(value) {
    const i = this.length % this.chunkSize;
    if (i === 0) this.chunks.push(new Float64Array(this.chunkSize)); // no copying
    this.chunks[this.chunks.length - 1][i] = value;
    this.length++;
  }
  get(index) {
    return this.chunks[Math.floor(index / this.chunkSize)][index % this.chunkSize];
  }
}

Verification and Regression Prevention

After changes, large_object_space should hold only the buffers you intend to keep, heap growth during batch operations should be smooth rather than stepped, and a heap timeline of the batch job should show no transient doubling at growth points. Compare major GC durations before and after moving numeric data into typed arrays; less pointer-heavy data means less marking work.

Keep an eye on the spaces in production metrics: export large_object_space and new_large_object_space alongside total heap, as in exposing Node.js memory metrics with prom-client. A steady climb in large object space usually means an array or cache is growing without bound, which is easier to spot there than in the total.

What large_object_space should hold After changes, large_object_space should hold only the buffers you intend to keep. Stepped heap growth during batch operations means large arrays are being reallocated as they grow. A transient doubling at growth points in the heap timeline means old and new backing stores are briefly both alive; preallocate or use typed arrays. Heap timeline of the batch job Fixed: large object space holds what you keep smooth, intended buffers only Large arrays reallocated as they grow; preallocate stepped growth Old and new backing stores alive together transient doubling

Edge Cases and Gotchas

Sliced and concatenated strings are small objects pointing at big ones

A short substring of a huge string can keep the huge string alive in large object space, as described in sliced strings retaining large parent strings. The large object is the parent, even though your code only holds the slice.

Holey arrays waste slots

new Array(1_000_000) creates a holey array; filling it out of order or leaving holes keeps the elements kind generic and every slot pointer-sized. For numeric data, typed arrays avoid this entirely.

The threshold is an implementation detail

The exact size limit for regular objects depends on V8 version and build flags. Do not design data structures around the precise number; design them to avoid giant single allocations in the first place.

Large objects in the young generation

Very short-lived large objects — a big temporary array created and dropped within one function — are allocated in new_large_object_space and freed by the next scavenge without copying. They are cheap to collect but still cost the allocation itself; repeatedly allocating them in a loop causes frequent scavenges.

Frequently Asked Questions

What size makes an object “large” in V8?

Objects above the maximum regular heap object size — on the order of 128 KB on typical 64-bit builds — are allocated in large object space. In practice that means arrays with more than about 16,000 pointer-sized elements, long strings and big hash tables.

Are large objects ever compacted?

No. Each large object sits on its own page and is not moved by compaction. When it dies, its pages are released. That avoids copying cost, but it means large objects cannot be packed together to reduce fragmentation.

Do ArrayBuffers count toward large object space?

The ArrayBuffer object itself is small; its contents live outside the V8 heap as external memory, reported separately (for example in process.memoryUsage().arrayBuffers). A large typed array therefore does not appear in large object space, but it does count toward the process’s memory.

Why did my heap jump by twice the array size?

Growing a generic array beyond its capacity allocates a new, larger backing store and copies the old contents into it. Until the old store is collected, both exist, so a single push can briefly double the memory for a big array. Preallocating or chunking avoids it.