Value representation: Smis, heap numbers, and tagged pointers
A JavaScript variable can hold a small integer one moment and a string the next, yet under the hood it has to be storable in a single machine word — and V8 solves this with tagged pointers, so that small integers live inline for free while everything else is a pointer to the heap.
Value representation: Smis, heap numbers, and tagged pointers
Every variable in the pipeline this module has been building — parsed, interpreted by Ignition, watched by the profiler, maybe compiled by TurboFan — is, underneath all of that, just some bits sitting in a machine word or a CPU register. A 64-bit machine word can hold exactly one thing: 64 bits. But a JavaScript value can be 42, or 3.14, or "hello", or { x: 1 }, or undefined. Somehow the engine has to fit all of those possibilities into the same fixed-size slot, and it has to be able to tell them apart cheaply, on every single read, because this check happens constantly — every arithmetic operation, every property access, every comparison. This lesson is about the representation trick that makes that possible, and why it means small integers are close to free while everything else costs a heap allocation.
The problem: one slot, any type
Think about what a variable slot actually is at the machine level — a location that holds one word of bits, nothing more. The engine can't know in advance what type will land there; that's the entire premise of dynamic typing. So whatever scheme V8 uses has to satisfy two things at once: it has to represent every JavaScript type in that same fixed-size slot, and given a slot's contents, the engine has to be able to determine "what type is this" without doing something expensive like a lookup table or a full memory scan on every access. The answer V8 (and every other JS engine) uses is to steal a few bits of the word itself as a label.
Tagged pointers: stealing a bit to say what something is
A tagged pointer reserves the low-order bit (or bits) of the word as a tag — a marker that says what kind of value this word actually is. On 64-bit V8 with pointer compression, values are represented in a compressed 32-bit form, and the scheme still boils down to the same idea: check one bit, and you know whether you're holding a number directly or a pointer to something else on the heap.
If the tag says "small integer," the entire value lives inline in the word itself — there is no separate allocation, no pointer to chase, nothing on the heap at all. V8 calls this a Smi ("small integer"): a 31-bit signed integer on 64-bit builds with pointer compression enabled (32-bit builds get a 31-bit range too, for parallel reasons rooted in how the tag bit is carved out of a 32-bit word). Reading a Smi is just reading the word and masking off the tag. Doing arithmetic on two Smis — a + b — can, in the fast case, be done directly on the tagged representation with no allocation whatsoever. This is why tight loops over small integer counters are some of the cheapest code you can write in JavaScript: the value never leaves the register, never touches the heap, never triggers GC bookkeeping.
If the tag says "pointer" instead, the word doesn't hold the value at all — it holds the address of an object living on the heap. That's true whether the value on the other end is a number too big for a Smi, a string, or a full object.
HeapNumbers: when a number doesn't fit
Not every number is a Smi. The moment a number is a float (3.14, 0.1), or an integer outside the Smi range, V8 can no longer store it inline — it needs more bits than the tagged word has room for. Instead it allocates a HeapNumber: a small boxed object on the heap that holds the full 64-bit IEEE 754 double, with the variable's slot holding a pointer to that box rather than the number itself.
let a = 42; // Smi — lives inline in the word, no allocation
let b = 42.5; // HeapNumber — allocated on the heap, slot holds a pointer
let c = 2 ** 31; // outside the 31-bit Smi range — also a HeapNumber
let d = a + 1; // still a Smi — stays inline
let e = a + 0.5; // becomes a HeapNumber the moment a float enters the arithmeticThat difference is not academic. A HeapNumber costs an allocation (competing for space in the young generation — lesson 7 covers exactly what that triggers) and a pointer dereference every time you read it, versus a Smi's zero-allocation inline read. It also means every HeapNumber your code creates is one more object the garbage collector eventually has to visit and reclaim. None of this makes floats slow in any absolute sense — V8's floating-point math is still fast — but it explains a genuinely surprising fact: two numerically "equal-looking" pieces of code, one staying in integers and one drifting into floats, can have measurably different allocation profiles, purely because of which representation each value ends up in.
Oddballs: the handful of special singletons
true, false, null, and undefined are what V8 calls oddballs — small, special heap objects that exist as unique singletons the engine allocates once and reuses everywhere. They're pointers like any other heap reference, but because there's only ever one true and one undefined in the entire running program, comparing them is as cheap as comparing two pointers for equality — no deep structural check needed, because there's nothing to be structurally deep about.
Strings: cons-strings and internalization
Strings live on the heap too, but V8 goes further than just boxing them, because string workloads have a shape worth optimizing for directly. Two techniques carry most of that weight:
Cons-strings (ropes). Naively, concatenating two strings means allocating a new buffer and copying both inputs into it — expensive, and worse if you're building a string piece by piece in a loop, where each += would re-copy everything accumulated so far. V8 instead builds a cons-string: a small object that just holds pointers to the left and right pieces being joined, without copying either. Repeated concatenation builds up a tree of these pieces rather than one long buffer. Only when something actually needs the flat character data — indexing into the string, comparing it, matching a regex against it — does V8 flatten the tree into a real contiguous buffer, lazily, at the point of use rather than at the point of concatenation.
Internalization. Identical string literals appearing in your source — the same property name accessed over and over, the same literal compared repeatedly — get interned: stored once in a table and shared, so that two occurrences of "length" in your code can point at the exact same heap object. That turns string equality checks in hot paths (property name lookups in particular) into pointer comparisons instead of character-by-character comparisons.
The payoff: typed arrays skip boxing entirely
This whole tagged/boxed picture explains something that otherwise looks like an arbitrary API choice: why Float32Array, Int32Array, and friends exist at all, and why graphics and audio code reaches for them so consistently.
A regular JavaScript array of numbers is an array of tagged values — each element is either a Smi inline or a pointer to a HeapNumber, and a mixed array of floats is an array of pointers to individually-boxed numbers scattered across the heap. A typed array throws the tagging scheme out entirely for its contents: it's a flat, contiguous buffer of raw, unboxed machine numbers — actual IEEE 754 floats or plain machine integers, back to back in memory, with no tag bits, no per-element boxing, and no pointer indirection to chase.
// Regular array: each element may be a Smi or a pointer to a HeapNumber.
// Floats here are boxed individually, scattered across the heap.
const samples = [0.1, 0.2, 0.3, 0.4];
// Float32Array: one contiguous buffer of raw 32-bit floats.
// No per-element boxing, no pointer chase — just bytes in a row.
const buffer = new Float32Array([0.1, 0.2, 0.3, 0.4]);That's exactly why the canvas, WebAssembly, and Web Audio code elsewhere on this site leans on typed arrays for anything numeric and bulk: pixel buffers, vertex data, audio sample frames. Every one of those workloads is doing arithmetic over thousands or millions of numbers per frame, and the difference between "dereference a pointer to a boxed float" and "read the next 4 bytes" repeated that many times is the difference between hitting the frame budget and missing it.
Where this goes next
Every HeapNumber, every cons-string node, every plain object is a heap allocation — and something eventually has to reclaim it once nothing references it anymore. Garbage collection picks up exactly there: how V8 decides an object is dead, why it splits the heap by object age, and why that collector's work can show up as a dropped frame if you're not careful about how much you allocate.
Go deeper
- V8 blog — Pointer compression — How V8 fits 64-bit heap pointers (and the Smi encoding alongside them) into a compressed 32-bit representation, straight from the team that shipped it.
- Mathias Bynens — JavaScript's internal character encoding — A detailed look at how V8 actually stores string data internally, relevant background for the cons-string picture in this lesson.
- MDN — JavaScript typed arrays — The full typed-array API surface this lesson's payoff section leans on, including the buffer/view distinction.
Check yourself
Answer out loud, as if an interviewer asked. If you hand-wave, reread that section.
- What problem does tagging solve, and what specifically does the tag bit distinguish in a tagged word?
- What is a Smi, what range of values can it hold, and why is arithmetic on Smis close to free?
- What is a HeapNumber, and name two concrete costs it has that a Smi doesn't.
- What are oddballs, and why does having a single shared singleton for each one make comparing them cheap?
- What is a cons-string, what problem does it avoid compared to eager concatenation, and when does V8 actually flatten one?
- What does string internalization let the engine turn a repeated property-name comparison into?
- Why does a Float32Array avoid the per-element boxing cost that a regular array of the same numbers pays, and why does that matter for canvas/audio/wasm code specifically?