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.
Step-by-Step Fix
- Measure the spaces. In Node.js, log
v8.getHeapSpaceStatistics()periodically and watchnew_large_object_spaceandlarge_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. - 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. - 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.
- 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. - 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
ArrayBuffermemory and major GC marking time falls. - 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.
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.
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.
Related
- Understanding the V8 Heap Layout and Memory Segments — the parent topic
- Reading V8 Heap Space Statistics in Node.js — interpreting every space in the statistics
- What Causes Memory Fragmentation in the V8 Engine — how regular spaces fragment and compact
- JavaScript Memory Fundamentals & Runtime Mechanics — the section overview