open atlas
↑ Back to track
JavaScript Engine internals JSE · 02 · 04

The V8 heap and object layout

The V8 heap is split into spaces — new space (two semi-spaces, bump-pointer allocation), old space, large-object space, code space, read-only space. Allocation in new space is a pointer bump until the semi-space fills, triggering a Scavenge.

JSE Middle ◷ 14 min
Level
FoundationsJuniorMiddleSenior

Allocating a fresh object in V8 is often faster than malloc. There is no free-list search, no size-class lookup — just a single pointer increment. The catch is that this only works because the memory it allocates into is disposable: a small nursery that gets evacuated wholesale every few milliseconds. Understanding how the heap is carved into spaces explains both why allocation is so cheap and why short-lived garbage is nearly free while long-lived data is expensive.

The heap is not one pool — it’s several spaces

V8 does not treat the heap as one undifferentiated blob. It partitions it into spaces, each with its own allocation strategy and its own relationship to the garbage collector. The split is driven by the generational hypothesis: most objects die young, so it pays to allocate them somewhere cheap and disposable and only “promote” the rare survivors.

  • New space (young generation) — small (a couple of MB up to tens of MB), where every normal object is born. It is split into two semi-spaces (from-space and to-space). Allocation here is the cheap path described below. New space is collected very frequently by a minor GC called the Scavenger.
  • Old space (tenured) — large, where objects that survive a couple of Scavenges get promoted. Collected rarely, by the major GC (mark-compact). Long-lived data lives here.
  • Large object space (LOS) — objects above a threshold (around 600 KB, roughly half a page-set) are allocated here directly and are never moved, because copying a huge object every GC would be wasteful. A big typed array or a giant string lands here.
  • Code space — holds JIT-compiled machine code from Sparkplug/Maglev/TurboFan. Often paired with a dedicated code-range so generated code sits in a known region.
  • Read-only space — immutable roots (the shared undefined Oddball, common Maps, internalised string roots). Being immutable, it can be shared across isolates, saving memory when you spawn many workers.

Together these spaces enforce one discipline: allocate cheap and temporary, pay later only if data survives. Without the separation, every allocation would need free-list management from birth, and every GC would have to handle code pages, immutable roots, and giant arrays the same way as ordinary short-lived objects — making both allocation and collection far more expensive.

(Historically there was also a separate Map space for hidden classes; later V8 folded it into old space.)

Bump-pointer allocation: why new is nearly free

Inside a semi-space, allocation is a bump-pointer. V8 keeps two values: a pointer to the next free byte (top) and the end of the space (limit). To allocate N bytes:

// Conceptual fast-path allocation in new space:
function allocate(bytes) {
  if (top + bytes > limit) return slowPath(bytes); // semi-space full -> trigger GC
  const addr = top;
  top += bytes;          // <-- the entire allocation: one pointer increment
  return addr;
}

That is it on the fast path: compare, then increment a pointer. No searching a free list, no coalescing, no size classes — the work malloc does. This is only possible because new space is collected by copying: when it fills, the live objects are moved out wholesale and the whole semi-space is reset to empty, so there is never fragmentation to manage.

When top would pass limit, the semi-space is full and V8 runs a Scavenge (minor GC): it copies the live objects from from-space into to-space (and promotes objects that have already survived once into old space), then swaps the roles of the two semi-spaces. Dead objects are not touched at all — they are simply left behind in the abandoned from-space, which makes collecting short-lived garbage essentially free. This is the deep reason “allocating lots of short-lived objects is cheap; keeping lots of objects alive is expensive.”

Object memory layout

Now zoom into a single plain object on the heap. Its layout, in order:

JS object in memory:
  [ Map pointer            ]  offset 0  — hidden class: kind + property layout
  [ properties backing-store ptr ]      — out-of-object named properties (overflow)
  [ elements backing-store ptr   ]      — indexed elements (the array part)
  [ in-object property slot 0    ]      — first fast property, stored inline
  [ in-object property slot 1    ]
  ...
  • Map pointer (offset 0) — the hidden class, as we established two lessons ago: what kind of object this is and where each named property lives.
  • Properties backing store — a pointer to an out-of-object array (or dictionary) holding properties that did not fit in the in-object slots.
  • Elements backing store — a pointer to the indexed elements (obj[0], obj[1], …); arrays keep their numeric-keyed data here, separate from named properties.
  • In-object property slots — the first several named properties are stored inline in the object’s own memory block (one memory load to read), the rest spill to the properties backing store (an extra dereference).

The number of in-object slots is fixed when the Map is created, which is why declaring your hot properties up front in a constructor keeps them in-object and fast. We go deep on hidden classes and in-object vs out-of-object properties in the hidden-classes unit; here, fix the skeleton: Map pointer, two backing-store pointers, then inline slots.

Heap and layout numbers
New space size
a few MB to tens of MB
New-space allocation
one pointer bump
Minor GC (Scavenge) cadence
every few ms under load
Large object space threshold
about 600 KB
Promotions to old space
after surviving ~2 Scavenges
Object header before user data
Map ptr + 2 backing-store ptrs
Quiz

Why can new-space allocation be a single pointer increment with no free-list search?

Quiz

A 5 MB typed array is allocated. Which space does it go to, and what's special about it there?

Order the steps

Order what happens when new-space allocation hits the limit and a Scavenge runs.

  1. 1 A bump allocation would push top past limit — the semi-space is full
  2. 2 V8 pauses and starts a Scavenge (minor GC)
  3. 3 Live objects are copied from from-space into to-space
  4. 4 Objects that already survived once are promoted to old space
  5. 5 The two semi-spaces swap roles; dead objects are abandoned, space reset
Why this works

Why split new space into two semi-spaces instead of one? Because the Scavenger is a copying collector: it needs a destination to copy the survivors into while it reads the originals. From-space is where objects live now; to-space is the empty destination. After copying, the roles swap, so the just-emptied from-space becomes the next to-space. The price is that half of new space is always idle reserve — a classic space-for-speed trade that makes collection a simple copy-and-swap with zero fragmentation. We unpack the Scavenger and the major GC fully in the garbage-collection unit.

Recall before you leave
  1. 01
    Name the V8 heap spaces and what each is for.
  2. 02
    Explain bump-pointer allocation and why it is so cheap.
  3. 03
    Describe the memory layout of a plain JS object.
Recap

V8 partitions its heap into spaces rather than treating it as one pool, driven by the generational hypothesis that most objects die young. New space, the young generation, is small and split into two semi-spaces; it is where every normal object is born, and allocation there is a single bump of a top pointer checked against a limit — cheaper than malloc because there is no free-list management. That cheapness depends on collection by copying: when a semi-space fills, the Scavenger copies live objects into the other semi-space, promotes objects that have already survived once into old space, swaps the two semi-spaces, and abandons the dead, so short-lived garbage costs almost nothing. Old space holds tenured survivors and is collected rarely by the major mark-compact GC; large object space holds objects above roughly 600 KB that are allocated directly and never moved; code space holds JIT machine code; and read-only space holds immutable roots shared across isolates. A single object is laid out as a Map pointer at offset 0, then pointers to its properties and elements backing stores, then its in-object property slots — the inline ones costing one load and the overflow ones costing a dereference. Now when you see a process where adding an object pool “to avoid allocation” actually made GC pauses longer, you know why: pooling promotes objects to old space, and old-space GC is the expensive one. Allocate freely; avoid retention.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 7 done
Connected lessons

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

Apply this

Put this lesson to work on a real build.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.