The heap & pointer compression
Learn how V8 organizes heap spaces and pages, allocates with bump pointers, compresses heap pointers, and reports Node heap limits.
- 01Name the main heap spacesExplain why V8 separates young objects, old objects, code, and very large objects instead of keeping one giant bag of memory.
- 02Model page allocationStep through a bump allocator that fills one regular page and moves to the next when the object no longer fits.
- 03Explain compressed pointers safelyDescribe the 4 GB pointer-compression cage, why official Node builds differ from Chrome, and how the V8 sandbox limits damage.
The heap is organized space
The beginner picture says JavaScript values live on a stack or a heap. That is useful, and the lessons on stack and heap and memory management explain that level. This lesson zooms into V8's heap: the managed memory area where objects, strings, arrays, functions, metadata, and code objects are placed.
A heap layout is an engine's internal arrangement of managed memory into spaces and pages. In V8, most small objects are placed on regular pages inside spaces such as new space, old space, and code space, while very large objects get large-object allocations. Pointer compression is V8's technique for storing in-cage heap pointers as 32-bit offsets.
We will not re-teach value tagging or Smis here; the value-representation lesson owns that. We also will not teach garbage collection algorithms here; the upcoming garbage-collection lessons, including gc-basics, own collection. Here the question is placement: where does the engine put things, and why does V8 sometimes store half-sized heap pointers?
This lesson follows Prototypes inside the engine in the same batch. After heap layout, Stack frames & calling conventions moves from managed heap memory to the machine stack used during calls.
New, old, code, and large-object spaces
A space is a region of V8's heap with one job. Fresh small objects normally begin in a young area called new space. Objects that survive long enough can end up in old space. Executable code objects live in code spaces because they have different permissions and accounting. Objects too large for regular pages go through large-object spaces.
const v8 = require("node:v8");const names = v8.getHeapSpaceStatistics().map((space) => space.space_name).sort();console.log(names.join(", "));The lesson test runs this probe in Node 22 on macOS and Linux-style CI. It asserts stable V8 12.4 space names such as new_space, old_space, code_space, large_object_space, new_large_object_space, and code_large_object_space. It does not assert exact byte counts.
| Space | Typical contents | Why the split exists |
|---|---|---|
new_space | Fresh, small objects | Cheap bump allocation and short-lived values. Collection details belong to the garbage-collection lessons. |
old_space | Objects that survived long enough to be promoted | Longer-lived ordinary objects and metadata. |
code_space | Executable code objects | JIT-produced code and related code objects need different permissions and accounting. |
large_object_space | Objects bigger than a regular-page object limit | Large objects get dedicated large-object pages instead of being squeezed into normal pages. |
new_large_object_space and code large spaces | Young or code objects that are already large | Node's space list exposes separate large-object spaces for these cases. |
| Read-only, trusted, and shared spaces | Engine-managed objects with special sharing or trust rules | They are visible in Node's statistics, but not where everyday app objects usually start. |
The exact list changes as V8 evolves. For example, this Node build also exposes read-only, trusted, shared, and corresponding large-object spaces. Treat those names as V8 implementation details, not ECMAScript requirements.
Pages and bump allocation
A page is a fixed-size chunk inside a paged heap space. Current V8 source keeps the regular page size at 256 KB for the common configuration: kPageSizeBits = 18, so 1 << 18 bytes. A regular object can be up to half that size in the source constant used by the lesson, so roughly 128 KB.
// V8 source, src/base/build_config.h:// constexpr int kPageSizeBits = 18;// constexpr int kRegularPageSize = 1 << kPageSizeBits;// V8 source, src/common/globals.h:// constexpr int kMaxRegularHeapObjectSize = (1 << (kPageSizeBits - 1));The fast allocation path is often a bump allocation: keep a pointer named something like nextFree, return the address at nextFree, and move nextFree forward by the object size. If the object does not fit, the allocator moves to a new page or a slower path.
A parking lot fills from the first free spot. No one searches the whole lot for each car; the marker moves forward after each one parks.
- In real life: The first free spot is known
- In JavaScript: The allocator keeps a
nextFreepointer - In real life: A car takes that spot
- In JavaScript: A small object gets the current address
- In real life: The marker moves forward
- In JavaScript:
nextFreebumps by the aligned object size - In real life: A full row opens another row
- In JavaScript: A full page sends allocation to another page
Where the analogy stops: Real heaps also have free lists, alignment rules, evacuation, and collection. This only shows the fast path for fresh space.
Watch a bump allocator fill a tiny page from left to right, then switch to a fresh page when the next object does not fit.
script
let page = 0;let nextFree = 0; function align16(bytes) { return Math.ceil(bytes / 16) * 16;} function allocate(label, requested) { const size = align16(requested); if (nextFree + size > pageSize) { page += 1; nextFree = 0; } const address = { page, start: nextFree, end: nextFree + size }; nextFree += size; return label + ": page " + address.page + ", bytes " + address.start + "-" + address.end;} console.log(allocate("header", 120));console.log(allocate("items", 380));console.log(allocate("names", 460));console.log(allocate("avatar", 180));Line 16 in the replay is the heart of the fast path: nextFree += size. Line 12 appears only when the next object crosses the page boundary. This is a teaching model; it does not inspect your browser's actual heap.
| Term | Meaning | Why it matters |
|---|---|---|
| Regular V8 page | Usually 1 << 18 bytes, or 256 KB, in current V8 source | Small and medium objects can share a page. |
| Maximum regular object | Current source defines it as half a regular page, about 128 KB | Bigger objects go to a large-object space. |
| Bump pointer | A nextFree address that moves forward after each allocation | Fast path: check room, return old nextFree, then bump. |
| Large-object page | One big allocation gets its own page-like region | Avoids copying or fitting huge payloads into regular pages. |
Why very large objects get their own space
Large allocations are awkward inside regular pages. If a backing store is bigger than the maximum regular object size, it cannot fit in the normal small-object rhythm. V8's large-object spaces handle those allocations separately. The object is not automatically a leak; it is just too large for the ordinary page path.
A lift carries small items, while big furniture goes by the stairs. The route depends on size, not on whether the furniture is wanted.
- In real life: Small items fit in the lift
- In JavaScript: Small objects fit regular pages
- In real life: A sofa does not fit
- In JavaScript: A huge backing store would waste or overflow a page
- In real life: Big furniture uses the stairs
- In JavaScript: V8 uses a large-object space
- In real life: The furniture is still yours
- In JavaScript: References still determine whether the object is live
Where the analogy stops: The stairs do not explain collection policy. The garbage-collection module covers when unreachable large objects are reclaimed.
const v8 = require("node:v8");if (typeof global.gc !== "function") throw new Error("run with --expose-gc");function used(spaceName) { return v8.getHeapSpaceStatistics().find((space) => space.space_name === spaceName)?.space_used_size ?? 0;}global.gc();const before = used("large_object_space");global.big = new Array(20000).fill(1);global.gc();const after = used("large_object_space");console.log(after > before);console.log(after - before >= 131072);The test runs this with --expose-gc, keeps a 20,000-element array alive, and asserts that large_object_space grows by at least the 128 KB regular-object threshold. The test checks the relation, not an exact byte total.
- A tiny
{ name: "Asha" }object just allocated inside a click handler. - A configuration object kept by the app for the whole session.
- Machine code generated for a hot JavaScript function.
- An array backing store bigger than roughly 128 KB in current V8.
- A small closure allocated for one validation pass and then dropped.
- A very large generated code object.
Sort each object or payload by the V8 space family it most likely belongs to in this lesson's model.
Pointer compression and the 4 GB cage
On a normal 64-bit machine, a pointer takes eight bytes. V8's pointer-compression blog explains the saving: if every V8 heap object pointer is inside one reserved 4 GB region, memory can store a 32-bit offset instead of the full address. Generated code adds the cage base back when it needs the real address.
Follow a BigInt teaching model for V8 pointer compression: full address, 32-bit offset, then base plus offset again.
script
const cageSize = 0x0000000100000000n; function compressPointer(address) { if (address < cageBase || address >= cageBase + cageSize) { throw new RangeError("address is outside the 4 GB cage"); } return Number(address - cageBase);} function decompressPointer(offset) { return cageBase + BigInt(offset >>> 0);} const address = cageBase + 0x12345678n;const compressed = compressPointer(address);console.log("0x" + compressed.toString(16).padStart(8, "0"));console.log("0x" + decompressPointer(compressed).toString(16));const cageBase = 0x0000000400000000n;const cageSize = 0x0000000100000000n; function compressPointer(address) { if (address < cageBase || address >= cageBase + cageSize) { throw new RangeError("outside the cage"); } return Number(address - cageBase);}0x4000000000x4ffffffff0x4123456780x123456780x4123456780x412345678 is inside the teaching cage. The compressed form is 0x12345678, and base + offset decompresses to 0x412345678.
const cageBase = 0x0000000400000000n;const cageSize = 0x0000000100000000n; function compressPointer(address) { if (address < cageBase || address >= cageBase + cageSize) { throw new RangeError("address is outside the 4 GB cage"); } return Number(address - cageBase);} function decompressPointer(offset) { return cageBase + BigInt(offset >>> 0);} const address = cageBase + 0x12345678n;const compressed = compressPointer(address);console.log("0x" + compressed.toString(16).padStart(8, "0"));console.log("0x" + decompressPointer(compressed).toString(16));Line 5 is the safety check in the model: an address outside the 4 GB cage cannot be represented by this compressed pointer. Line 8 stores the 32-bit offset. Line 12 decompresses by adding the cage base back. V8's real representation also interacts with tagged values and Smis; the neighboring value-representation lesson owns those details.
| Idea | What it stores | Important caveat |
|---|---|---|
| Full heap pointer | A machine address wide enough for the process | Eight bytes on a normal 64-bit build. |
| Compressed heap pointer | A 32-bit offset inside one reserved cage | Four bytes in memory, decompressed as cage base plus offset before use. |
| The 4 GB cage | The address range that 32 bits can name | It limits where compressed heap objects can live, not how every Node process must be configured. |
| Official Node 22 builds | Pointer compression and sandbox flags are 0 in process.config.variables | Node can keep larger heaps than Chrome-style compressed-pointer builds. |
| Chrome on 64-bit | V8's public posts describe pointer compression and the sandbox as Chrome features | Chrome and Node are both V8 embedders, but their build flags differ. |
console.log(%IsSmi(2 ** 31 - 1));console.log(%IsSmi(2 ** 31));This official Node build does not enable pointer compression, so a native V8 probe confirms that 2 ** 31 - 1 is still a Smi while 2 ** 31 is not. Chrome's pointer-compressed 64-bit V8 uses 31-bit Smi payloads, as the V8 pointer-compression article describes. The value-representation lesson owns the full tagging story.
The V8 sandbox
Pointer compression saves memory. The V8 sandbox is a security design. The V8 sandbox blog describes isolating V8 heap memory so a V8 heap corruption bug cannot directly reach the rest of the host process. In the software design, in-sandbox pointers are offsets, and references to memory outside the sandbox go through tables such as an external pointer table.
The V8 blog describes pointer compression and the sandbox as Chrome 64-bit features. Official Node 22 builds used for this lesson report process.config.variables.v8_enable_pointer_compression === 0 and process.config.variables.v8_enable_sandbox === 0. Node embeds V8 with different build choices.
console.log(process.config.variables.v8_enable_pointer_compression);console.log(process.config.variables.v8_enable_sandbox);The sandbox is not a replacement for the browser process sandbox, and it is not a promise that every bug becomes harmless. It is a defense layer: corrupted in-cage data should not be enough to read or write arbitrary process memory without a sandbox bypass.
Heap size limits
v8.getHeapStatistics().heap_size_limit reports V8's managed-heap ceiling for a Node process. It is not the same as your operating system's resident memory, and it is not an exact prediction of when a real app will fail. Still, it is useful evidence when you change a V8 heap-size flag.
node --max-old-space-size=64 -e "console.log(require('node:v8').getHeapStatistics().heap_size_limit)"node --max-old-space-size=256 -e "console.log(require('node:v8').getHeapStatistics().heap_size_limit)"The test launches two child Node processes and asserts that --max-old-space-size=256 reports a larger heap_size_limit than --max-old-space-size=64. Exact numbers vary, so the lesson never pins them.
If a Node process runs out of heap, increasing the limit can buy time, but it does not explain why memory grows. Use heap snapshots, allocation profiles, and application knowledge to find retained data.
What developers should do
Most frontend work should not depend on V8 page sizes or build flags. Use this knowledge to interpret measurements. A giant array, image buffer, or parsed JSON payload may appear in large-object accounting. A short-lived stream of small objects may churn in new space. A retained cache can grow old space. Each clue suggests a measurement to run, not folklore to apply blindly.
- Use heap snapshots to identify what is retained, then fix ownership or cleanup.
- Measure before rewriting data structures for a page-size or pointer-compression guess.
- Avoid keeping huge arrays or strings reachable after the UI no longer needs them.
- For Node services, treat
--max-old-space-sizeas a capacity knob, not a leak fix.
If the issue is collection behavior, continue in the garbage-collection module. If the issue is call memory, continue to stack frames next.
Common misconceptions
- “The JavaScript spec says objects are in new space or old space.” No. Spaces are V8 implementation details.
- “Pointer compression means Node always has a 4 GB heap.” Official Node builds here do not enable pointer compression.
- “A large-object allocation is a leak.” It is only a placement choice. A leak means unwanted reachability over time.
- “The sandbox and garbage collector do the same job.” The sandbox limits exploit reach; the collector reclaims unreachable objects.
- “Heap limit equals total process memory.” It covers V8 managed heap, not every native allocation or OS page.
| Idea | Precise meaning | Do not confuse it with |
|---|---|---|
| JavaScript semantics | Objects behave according to the ECMAScript spec | Heap spaces, pages, and cages are V8 strategies, not language guarantees. |
| Pointer compression | Stores in-cage heap pointers as 32-bit offsets | It is not the same thing as the V8 sandbox or garbage collection. |
| Heap limit | A configured ceiling for V8's managed heap | It is not the exact resident memory your operating system shows. |
| Large-object space | A placement choice for large allocations | It does not mean the object is leaked or immortal. |
Practice exercises
In the bump-allocation replay, what page does avatar land on?
// The first page has only 48 bytes left.
// The next aligned allocation needs 192 bytes.The avatar allocation lands on page 1 because 976 + 192 is greater than the 1024-byte teaching page.
Use the pointer-compression model to compute the compressed form of base + 0x20n.
const base = 0x400000000n;
const address = base + 0x20n;
// Print the compressed offset as eight hex digits.const base = 0x400000000n;
const address = base + 0x20n;
const offset = Number(address - base);
console.log("0x" + offset.toString(16).padStart(8, "0"));The offset is 32 decimal, so the padded compressed form is 0x00000020.
A learner tries to compress exactly base + 4 GB. Is that address inside or outside the cage?
base + 4 GB is outside the cage because the last valid byte is base + 4 GB - 1.
If you raise --max-old-space-size, should the reported heap limit be higher or lower?
Raising --max-old-space-size should make heap_size_limit higher in the Node probe.
Your dashboard grows old-space memory after every route change. What should you do before rewriting data structures?
Measure first: take a heap snapshot, profile allocations, or reproduce the memory growth before changing app code.
What values does official Node 22 report for v8_enable_pointer_compression and v8_enable_sandbox in this lesson?
The lesson's Node probe reports 0 for pointer compression and 0 for the V8 sandbox.
Check your understanding
Question 1 of 8Why does V8 split the heap into spaces?
Choose an answer to see the explanation.
Question 2 of 8What does this bump-allocation snippet print last?
Read the code, then predictconst pageSize = 1024; let page = 0; let nextFree = 976; const size = 192; if (nextFree + size > pageSize) { page += 1; nextFree = 0; } console.log(page);Choose an answer to see the explanation.
Question 3 of 8What does this pointer-compression model print first?
Read the code, then predictconst base = 0x400000000n; const address = base + 0x12345678n; const compressed = Number(address - base); console.log("0x" + compressed.toString(16).padStart(8, "0"));Choose an answer to see the explanation.
Question 4 of 8Which statement about official Node 22 builds and Chrome is correct for this lesson?
Choose an answer to see the explanation.
Question 5 of 8Why can a large object skip regular page bump allocation?
Choose an answer to see the explanation.
Question 6 of 8What does raising
--max-old-space-sizedo tov8.getHeapStatistics().heap_size_limit?Choose an answer to see the explanation.
Question 7 of 8What is the V8 sandbox trying to prevent?
Choose an answer to see the explanation.
Question 8 of 8Which production habit follows from this lesson?
Choose an answer to see the explanation.
Key takeaways
- V8 splits managed heap memory into spaces because fresh objects, old objects, code, and huge objects need different handling.
- Regular pages are 256 KB in current V8 source for the common configuration; the maximum regular object size is half a page.
- Bump allocation is the fast path: return the current next-free pointer, then move it forward if the object fits.
- Pointer compression stores 32-bit offsets inside a 4 GB cage; official Node builds here differ from Chrome and report the feature disabled.
- The V8 sandbox is a security layer that keeps heap corruption from directly reaching the rest of the process.
Remember the one-liner.
V8 makes the heap fast and compact by grouping objects into purpose-built spaces, allocating regular objects page by page, and, in Chrome, storing heap pointers as in-cage offsets.
Up next: Stack frames & calling conventions, where you will see what a function call puts on the machine stack.