Pointer tagging and pointer compression
The low tag bit distinguishes a Smi from a HeapObject pointer; the engine masks it to dereference. Pointer compression (V8 8.0, 2020) then stores those pointers as 32-bit offsets inside a 4GB cage, cutting heap size by roughly 40%
You measure a Node 12 process and a Node 14 process running the identical object-heavy workload. Node 14 uses roughly 40% less heap for the same data — same objects, same code. No one changed the program. V8 changed how it stores a pointer: from a full 8 bytes to a 4-byte offset into a fixed region. That single representation change is one of the largest memory wins in V8’s history, and it has a sharp edge.
The tagging scheme, precisely
We have seen that a value word’s low bit is the tag. Now the exact convention, which is the inverse of what beginners guess:
- Smi: low bit 0. The integer occupies the upper bits. No masking needed to use the integer in tagged arithmetic.
- HeapObject pointer: low bit 1. The real address is the word with that bit cleared.
This works because heap allocations are aligned (to at least a word boundary), so a real address always ends in …000. V8 adds 1 to mark it as a pointer. To dereference, the engine clears the low bit — a single AND / subtract:
// Conceptual untag (a real V8 dereference is one machine instruction):
const TAG = 1;
function isPointer(word) { return (word & TAG) === 1; }
function untag(word) { return word & ~TAG; } // clear the tag bit -> real address
// then load from the resulting addressSo the cost of dynamic typing is: one bit test to classify, and one mask before any object access. Everything in V8 is built so these two operations are essentially free on the hot path.
Why pointers were the heap’s biggest cost
Object-oriented JavaScript is pointer-heavy. Think about what a typical object graph holds: every HeapObject’s first word is a pointer (its Map). Every property that is itself an object is a pointer. Arrays of objects are arrays of pointers. A linked list, a tree, a React fibre graph — overwhelmingly pointers.
On a 64-bit machine, each of those pointers is 8 bytes. But almost no real program needs a 64-bit address space for its JS heap — a few gigabytes is plenty. So 4 of those 8 bytes are, in practice, always zero. Across a graph with millions of pointers, that is a lot of wasted memory and wasted cache: every cache line holds half as many useful pointers as it could.
Pointer compression: 32-bit offsets in a cage
V8 8.0 (shipped 2020, on by default in Chrome and Node from then on) fixed this with pointer compression. The idea:
- Allocate the entire JS heap inside a single 4GB-aligned region — the cage. Its start address is the base, held in a dedicated register.
- Store every HeapObject pointer not as a full 64-bit address, but as a 32-bit offset from the base.
- To dereference, reconstruct the real address as base + offset — one
addagainst the base register.
Together these three steps mean that only the 32-bit offset travels through memory — the shared base never leaves its register. Without step 1 (the aligned cage) there would be no base to share; without step 3 there would be no way to recover the full address without storing it. Remove any one step and you lose either the 4-byte saving or the ability to dereference at all.
A compressed pointer is 4 bytes instead of 8. The Smi/pointer tag still lives in the low bit of the 32-bit value, so the scheme composes cleanly with tagging.
The payoff is large and was measured across real workloads:
- Heap size dropped ~40% on typical web and Node workloads — because so much of the heap is pointers.
- Cache locality improved, since twice as many pointers fit per cache line, which often speeds up pointer-chasing code despite the extra add.
The cost: a 4GB cap and an add
Nothing is free. Pointer compression’s price:
- A hard 4GB heap cap per isolate. A 32-bit offset can only address 4GB. If you genuinely need a larger JS heap in one isolate, you must build V8 with compression disabled (or use multiple isolates / worker threads, each with its own cage). For almost all apps, 4GB is far more than enough; for a few data-processing giants, it is a real wall.
- A decompression step. Every pointer load now does
base + offsetinstead of using the stored value directly. This is a single cheapadd, and the locality win usually more than pays for it — but it is non-zero, and it is why compression is a tradeoff, not a pure win.
Contrast with the old full-64-bit scheme (pre-8.0): pointers were stored as complete addresses, so no decompression add and no 4GB cap — but every pointer cost 8 bytes and cache lines were half-utilised. V8 judged the memory and locality win worth the cap for the overwhelming majority of users.
- Stored pointer size, pre-8.0
- 8 bytes (full 64-bit)
- Stored pointer size, with compression
- 4 bytes (32-bit offset)
- Typical heap reduction
- about 40%
- The cage size
- 4 GB, aligned
- Heap cap per isolate (compressed)
- 4 GB
- Decompression cost per load
- one add (base + offset)
In V8's tagging scheme, what does the low bit of a value word being 1 indicate, and what must the engine do before dereferencing?
What is the principal cost V8 accepts in exchange for pointer compression's ~40% heap reduction?
Order the steps to dereference a compressed, tagged HeapObject pointer and read its Map.
- 1 Read the 32-bit value from the pointer slot
- 2 Test the low bit — it's 1, so this is a pointer (not a Smi)
- 3 Clear the tag bit to get the raw 32-bit offset
- 4 Reconstruct the real address as cage base + offset
- 5 Load the word at offset 0 of that address — the Map pointer
▸Why this works
Why a single 4GB aligned cage rather than an arbitrary base? Alignment lets V8 compute the base by simply masking the low 32 bits off any in-cage address, and lets compression/decompression be plain bit operations rather than general arithmetic. It also means the base register rarely changes, so the CPU’s prediction and the compiler’s code-motion can treat it as a near-constant. The cage is per-isolate, which is why spawning multiple worker threads (each its own isolate, each its own 4GB cage) is the standard way to exceed the single-isolate heap cap.
- 01State V8's exact tag convention and what the engine does to dereference a pointer.
- 02What is pointer compression and how does it cut heap size by ~40%?
- 03What does pointer compression cost, and how do you work around the cap?
V8’s tag convention is precise: the low bit of a value word is 0 for a Smi (integer in the upper bits) and 1 for a HeapObject pointer, which works because aligned allocations make a real address end in zero bits, so V8 adds 1 to mark a pointer and clears it with a single mask to dereference. Object-oriented heaps are dominated by pointers — every Map pointer, every object-valued property, every array of objects — and on a 64-bit machine each pointer was a full 8 bytes whose top 4 bytes were almost always zero. Pointer compression, shipped in V8 8.0 in 2020, places the whole JS heap in one 4GB-aligned cage with its base in a register and stores each pointer as a 32-bit offset, reconstructing the real address as base + offset on every load. This halves pointer width, cutting typical object-heavy heaps by about 40% and improving cache locality enough that pointer-chasing code often gets faster despite the extra add. The costs are a hard 4GB heap cap per isolate (worked around by disabling compression or using multiple isolates/workers) and one add per dereference — making compression a deliberate memory-for-cap tradeoff that V8 judged worthwhile for nearly every user. Now when you see a Node process hitting a 4GB heap cap that nothing in user code explains, or you wonder why upgrading from Node 12 to Node 14 dropped memory usage without any code change, you know exactly which commit to blame — and why it was the right call.
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.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.