Smis, doubles, and HeapNumbers
A Smi is a 31-bit signed integer packed into a 64-bit word — arithmetic in one instruction, no allocation. Step outside the Smi range or use a fraction and the number becomes a heap-allocated HeapNumber holding a double. That transition is V8's classic deopt trigger
A counter loop runs at full speed for hours, then one day a value ticks past two billion and the same loop is suddenly 30% slower — permanently, until the process restarts. Nothing in your code changed. What changed is that your integer stopped being a Smi and became a boxed HeapNumber, and the compiled code that assumed “this is always a small integer” had to bail out. This lesson is about the line that integer crossed.
The Smi: an integer that costs nothing
Recall from the previous lesson that a value word is 64 bits and the low bit is the tag. For a Smi, the tag bit is 0 and the integer lives in the upper 32 bits of the word. That leaves room for a 31-bit signed integer on 64-bit V8 — a range of roughly ±2³⁰, i.e. about ±1.07 billion. (The bottom 32 bits are zero; this is sometimes described as the value being “shifted left by 32”.)
Why does this matter so much? Because a Smi needs no heap allocation and no dereference. The number is in the word. Adding two Smis is essentially one CPU instruction — the engine can use native integer arithmetic and just keep the tag arithmetic-friendly. No object is created, no memory is touched, no garbage is produced. This is the single fastest representation of a number JavaScript has, and it is why integer-heavy loops can rival C.
let sum = 0;
for (let i = 0; i < 1000; i++) sum += i;
// i and sum stay well inside Smi range:
// every += is integer math on a tagged word, zero allocations.The HeapNumber: a box on the heap
The moment a number does not fit the Smi mould, V8 must represent it as a full IEEE-754 double (64 bits). But a double does not leave room for a tag bit in the same trick. So V8 boxes it: it allocates a HeapNumber — a tiny HeapObject whose payload is the 64-bit double — and the value word becomes a pointer to that box.
A number becomes a HeapNumber when it is any of:
- Outside the Smi range — magnitude beyond roughly ±2³⁰ (e.g.
2 ** 31, a large timestamp in milliseconds, a big counter). - Not an integer — anything with a fractional part, like
0.5orMath.PI. - A special double —
NaN,Infinity,-0.
Together these three cases mean that any number carrying real-world identity — a UTC timestamp, a floating-point sensor reading, a special sentinel — will always be a boxed HeapNumber. If you see one appearing in a tight loop, that is your allocation budget draining.
Now reading the number costs what every HeapObject costs: a pointer dereference and a memory load, plus the original allocation cost and the future GC cost of collecting the box. A HeapNumber is not catastrophic for a one-off value, but in a hot loop, allocating one per iteration is a real tax.
Unboxed doubles: V8’s escape hatch
If every fractional number meant a HeapNumber allocation, numeric arrays would be unusable. V8 avoids this with unboxed doubles. When V8 knows a storage location will only ever hold doubles, it stores the raw 64-bit double directly, with no box and no pointer:
- A
PACKED_DOUBLE_ELEMENTSarray (created when every element is a double) stores raw doubles back-to-back, like a Cdouble[]. - A double field on an object: when a property has only ever held doubles, V8 stores the raw double inline in the object (a MutableHeapNumber / double-field representation), updating it in place instead of re-boxing.
- A
Float64Array(a typed array) stores raw doubles by definition — there is never a HeapNumber.
The catch with object double-fields: because the storage is the raw double, not a tagged word, the object’s hidden class records “this field is a double”. If you later store a non-number there, V8 has to migrate the object’s shape — another reason to keep field types stable.
The classic deopt: Smi overflow in a hot loop
Here is the war story. TurboFan watches a hot loop, sees that a variable has always been a Smi, and compiles specialised integer machine code with a guard: “assume this stays a Smi.” That code is fast precisely because it skips the float path entirely.
Then one value overflows ±2³⁰. The guard fails. V8 deoptimises: it throws away the specialised code, falls back to the interpreter (Ignition), and the value becomes a HeapNumber. If the overflow keeps happening, V8 may re-optimise on the double path — but you have paid the deopt, and you are now allocating HeapNumbers.
// --trace-deopt would show a bailout the first time `acc` overflows Smi range.
function hash(arr) {
let acc = 0;
for (let i = 0; i < arr.length; i++) {
acc = (acc * 31 + arr[i]); // acc grows past 2**30 fast -> Smi overflow -> deopt
}
return acc;
}The fixes, in order of preference:
- Bound the integer range. Keep accumulators within Smi range — e.g. mask with
| 0/>>> 0or take a modulus each step so the value never overflows (acc = (acc * 31 + arr[i]) | 0keeps it a 32-bit int; V8 handles| 0results efficiently). - Specialise on double from the start. If the data is genuinely floating-point, store it in a
Float64Arrayor seed the array with a double so V8 picksPACKED_DOUBLE_ELEMENTSimmediately and never assumes Smi. - Use
Math.froundwhen you want 32-bit float semantics, which also signals V8 to specialise on float rather than oscillate between Smi and double.
You can confirm any of this with node --trace-deopt --trace-opt your.js, or in d8 with %OptimizeFunctionOnNextCall(fn) + %GetOptimizationStatus(fn) under --allow-natives-syntax.
- Smi range (64-bit V8)
- 31-bit signed, about ±2³⁰
- Smi arithmetic cost
- ~1 CPU instruction
- Smi allocation
- 0 (lives in the word)
- HeapNumber cost to read
- alloc + pointer deref
- HeapNumber payload
- 64-bit IEEE-754 double
- Float64Array element
- raw double, never boxed
Which of these values is stored as a Smi (inline, no allocation) on 64-bit V8?
A TurboFan-compiled loop runs fast for millions of iterations, then deopts once an accumulator passes ~2³⁰. What happened?
Order the events when a hot integer loop's accumulator finally overflows the Smi range.
- 1 TurboFan compiles the loop, specialising on 'accumulator is a Smi'
- 2 Iterations run as one-instruction integer math, no allocation
- 3 The accumulator value exceeds about ±2³⁰
- 4 The Smi guard in the compiled code fails -> deopt back to Ignition
- 5 The value is reboxed as a HeapNumber holding a double
▸Edge cases
-0 and NaN are doubles, not Smis, so Object.is(-0, 0) is false and [-0].includes(-0) works while reasoning about them as integers does not. Array element kinds care too: an array of integers is PACKED_SMI_ELEMENTS; push a single 0.5 and it widens — one-way — to PACKED_DOUBLE_ELEMENTS, after which even the integers in it are stored as raw doubles. The widening transition is irreversible for that array.
- 01What exactly is a Smi on 64-bit V8, and why is Smi arithmetic so cheap?
- 02When does a number become a HeapNumber, and what does that cost?
- 03Why does an integer overflowing the Smi range cause a TurboFan deopt, and how do you avoid it?
On 64-bit V8 a number is represented in one of two ways. A Smi is a 31-bit signed integer (range about ±2³⁰) packed into the upper bits of the value word with the low tag bit 0; it needs no allocation and no dereference, so Smi arithmetic is roughly one CPU instruction and produces no garbage. Any number that does not fit — outside the Smi range, fractional, or a special value like NaN, Infinity, or -0 — must be a 64-bit IEEE-754 double, which V8 boxes into a heap-allocated HeapNumber reached through a pointer, costing an allocation, a dereference on every read, and later GC. To keep doubles cheap V8 stores them unboxed where it knows the type: Float64Array elements, PACKED_DOUBLE_ELEMENTS arrays, and object double-fields hold raw doubles with no box. The classic production deopt is a hot loop where TurboFan specialised on the Smi assumption and a value overflows ±2³⁰: the guard fails, V8 bails to the interpreter, and the value becomes a HeapNumber. The fixes are to bound integer ranges (mask with | 0, take a modulus) so values stay Smis, or to specialise on double from the start with Float64Array or Math.fround — verifiable with —trace-deopt and —trace-opt. Now when you see a counter loop go slow after running for hours, or a flamegraph showing unexpected HeapNumber allocations, you know exactly which line in your data caused the crossing — and which one-line fix brings it back.
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.