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

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

JSE Middle ◷ 13 min
Level
FoundationsJuniorMiddleSenior

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.5 or Math.PI.
  • A special doubleNaN, 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_ELEMENTS array (created when every element is a double) stores raw doubles back-to-back, like a C double[].
  • 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 / >>> 0 or take a modulus each step so the value never overflows (acc = (acc * 31 + arr[i]) | 0 keeps it a 32-bit int; V8 handles | 0 results efficiently).
  • Specialise on double from the start. If the data is genuinely floating-point, store it in a Float64Array or seed the array with a double so V8 picks PACKED_DOUBLE_ELEMENTS immediately and never assumes Smi.
  • Use Math.fround when 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 vs HeapNumber, by the numbers
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
Quiz

Which of these values is stored as a Smi (inline, no allocation) on 64-bit V8?

Quiz

A TurboFan-compiled loop runs fast for millions of iterations, then deopts once an accumulator passes ~2³⁰. What happened?

Order the steps

Order the events when a hot integer loop's accumulator finally overflows the Smi range.

  1. 1 TurboFan compiles the loop, specialising on 'accumulator is a Smi'
  2. 2 Iterations run as one-instruction integer math, no allocation
  3. 3 The accumulator value exceeds about ±2³⁰
  4. 4 The Smi guard in the compiled code fails -> deopt back to Ignition
  5. 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.

Recall before you leave
  1. 01
    What exactly is a Smi on 64-bit V8, and why is Smi arithmetic so cheap?
  2. 02
    When does a number become a HeapNumber, and what does that cost?
  3. 03
    Why does an integer overflowing the Smi range cause a TurboFan deopt, and how do you avoid it?
Recap

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.

recallapplystretch0 of 5 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.

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.