Numbers: Smis, Doubles and Boxing

Here is a fact that sounds wrong. In JavaScript, every number is a 64-bit float. The integer 5, the fraction 5.5, 2 ** 40, the length of an array, the index in your loop: one single type, IEEE 754 double, no separate int. The spec is emphatic about it.

And yet this counter is about as cheap as a line of code gets, and it never touches the heap while it runs:

let sum = 0;
for (let i = 0; i < 1_000_000_000; i++) {
  sum += i;
}

Swap the loop to lean on fractional or very large numbers and you can push the engine into allocating a throwaway object on the heap for values that, to your eyes, are just numbers. Same type on paper. Different cost in practice.

The gap comes down to how V8 physically stores a number. It does not store them all as 64-bit floats, because the single most common thing programs do with numbers is integer arithmetic (counters, indices, sizes, ids), and boxing every one of those into an 8-byte float object would be slow and wasteful. So V8 keeps a couple of physical representations and slides a value between them as needed. Learn the two main ones and the boundary between them, and the cost of numbers stops being a mystery.

The spec says float, the engine says “it depends”

The language model is one number type. The engine’s model is a small set of representations that all behave like that one type but cost different amounts to store and read. Two matter most:

  • A Smi (“small integer”) is a small whole number stored inline, right in the slot where the value lives. No separate object, no allocation, nothing to chase. A loop counter is almost always a Smi.
  • A HeapNumber is a number that could not fit as a Smi, so V8 puts the raw 64-bit double in a little object on the heap and stores a pointer to it. That is called boxing. It costs an allocation, a pointer to follow, and eventually some work for the garbage collector.

A “slot” here means anywhere a single value sits: a variable, an object field, an element of an array. The question this whole article answers is: when you put a number in a slot, does the number live in the slot, or does the slot just hold a pointer to the number? That one difference is most of the cost.

One slot, and a single bit that decides everything

How can one slot sometimes hold a whole integer and sometimes hold a pointer, and how does the engine tell them apart without asking? With one bit.

V8 allocates every heap object at an address that is a multiple of the machine word, so real object pointers always have their lowest bit(s) equal to zero. That leaves the low bit free to mean something. V8 uses it as a tag: if the lowest bit of the slot is 0, the rest of the slot is a small integer sitting right there. If the lowest bit is 1, the slot is a pointer to a heap object, and you follow it to find the value.

CASE 1: a small integer4231-bit payload, inline0tagthe value IS the slotno allocation, no chaselow bit 0 → read the integer straight out of the slot. Done.CASE 2: fractional, or too big for a Smipointeran address, not the number1tagHeapNumber3.14 as a raw 64-bit doublelow bit 1 → follow the pointer to the heap, then read the 8 bytes there.Slot layout is illustrative and reflects a typical pointer-compressed build.
A single slot, read two ways. If the low tag bit is 0 the slot itself holds a small integer inline. If the tag bit is 1 the slot holds a pointer you must follow to a HeapNumber on the heap.

That is the whole trick. One bit turns an ordinary slot into either a value or a reference, and the engine branches on it constantly. It is the same tagging idea a runtime uses to fit different kinds of thing into one uniform slot, applied to the most common thing of all: an integer.

Smis: integers that cost nothing to store

A Smi is the cheap case, and it is cheap for a concrete reason. The integer is already sitting in the slot with a 0 bit stapled to the bottom. Reading it is a shift. Writing it is a shift. There is no object to allocate when the value is born and nothing for the collector to reclaim when it dies, because there is no separate object at all. Add two Smis and, in optimised code, the engine can often keep the result in a machine register as a raw integer and never tag it at all.

The catch is the “small” in small integer. A Smi does not get the full 64 bits, because one bit is spent on the tag and, on modern builds, the tagged value itself is only 32 bits wide. On a typical build with pointer compression (which is what Chrome and Node ship on 64-bit today), a Smi holds a signed 31-bit integer: roughly the range minus 2^30 to 2^30 minus 1, which is about plus or minus 1.07 billion.

Inside that range, whole numbers are Smis: your i, your array.length, most ids, most counts, the indices you use to walk an array. This is also why an array of small integers is the tightest thing V8 can store, a PACKED_SMI_ELEMENTS array, covered in elements kinds. The array does not even keep the tag bits; it knows every element is a Smi and packs them.

Now put the cheap case next to the expensive one and look at what actually differs when you read the value.

read a Smislot: 42 (inline)1. read the slot.that is the whole thing.1 step0 allocations, 0 pointer chasesread a boxed doubleslot: pointerHeapNumber4.2 (8 bytes)1. read slot → pointer2. follow it to the heap3. read the 8 bytes3 steps + an allocationthe box was allocated, and the GCwill have to reclaim it later
Reading a Smi is one step: the value is already in the slot. Reading a boxed double is a chase: read the slot to get a pointer, follow it to the heap, then read the eight bytes, and the box had to be allocated first.

None of these steps is expensive on its own. A pointer chase is nanoseconds. But multiply “an allocation plus an indirection” by the millions of iterations of a hot loop and the difference stops being academic. This is the mechanism under the vague folk wisdom that “integer math is faster in JS”. It is not that the CPU adds integers faster than floats. It is that the integer never had to become a heap object.

When a number cannot be a Smi

Two things push a number out of the Smi world and onto the heap:

  1. It is fractional. 3.14, 0.5, anything with a decimal part cannot be a small integer, so it becomes a double.
  2. It is too big (or too small). A whole number past the Smi range, like 2_000_000_000 on a 31-bit build, does not fit the inline payload, so it also becomes a double.

Either way, when that double has to live in a slot, V8 boxes it into a HeapNumber: it allocates a small object, writes the 64 bits in, and stores a pointer. The HeapNumber is immutable, which matters more than it sounds. If you have a variable holding 3.14 and you reassign it to 3.15, V8 does not edit the box in place; the value in the box never changes. A fresh double means a fresh HeapNumber.

That immutability is exactly what makes a naive fractional loop expensive. Watch what happens when a value that has to live on the heap changes on every pass:

i = i + 1 (stays a Smi)7same slot, rewritten in placethe integer never leaves the slot0 bytes of garbagenothing for the collector to doseed = next(seed) (a double)0.420.710.130.55newesteach update allocates a new HeapNumber;the old box is dropped on the spot.a pile of short-lived garbageboxes born and abandoned every step,handed to the garbage collector
A Smi counter increments in place and produces no garbage. A fractional value that has to live on the heap gets a brand-new HeapNumber on each update, and the previous box becomes young-generation garbage the collector must sweep.

Crossing the Smi boundary

The boxing you can most easily see coming is a counter that grows out of Smi range. Nothing about the value looks special. It is still a whole number. It just got too big for the inline slot.

Smi territory (inline, no allocation)the ~2^30 limit1,073,741,8230 …+1HeapNumber1,073,741,824 as a doubleallocated on the heapThe value did not become fractional. It is still a plain integer.But it stopped fitting the inline slot, so its storage moved to the heap.Where exactly the limit sits depends on the build. The move itself does not.
A whole number sits inline as a Smi right up to the top of the range. Add one past the limit and the same integer can no longer fit the slot, so V8 allocates a HeapNumber and the slot becomes a pointer to it, even though the value is still an ordinary integer.

In everyday code this is nothing to fear. Counters that reach a billion are rare, and even when they cross, one extra allocation for a long-lived value is invisible. It matters in the narrow case where a huge-integer value is created and thrown away in bulk (say, hashing or id generation in a tight loop), because there each value is a fresh box. The fix, when it is actually a problem, is usually to keep the hot values inside Smi range or to reach for a fixed-layout structure, which is the next idea.

Unboxed doubles: the raw 64-bit escape hatch

Boxing every double would be a disaster for genuinely numeric code. A physics engine stepping over a million particle coordinates does not want a million little pointer-chased boxes. So V8 has contexts where it stores the raw 8-byte double without a box, laid out flat.

The cleanest example is a packed-double array. When V8 knows an array holds only doubles, it stores the raw floats back to back in one contiguous block, no per-element box and no pointers to chase. Compare that to a generic array that happens to hold numbers, where each slot is a tagged value and any boxed number in it is a separate heap object:

packed double arrayraw floats, inline, one block1.52.53.54.58B8B8B8Bread straight from the block0 boxes, 0 pointer chasesgeneric array of numberseach slot is a pointer to a boxptrptrptrptr1.52.53.54.5four separate HeapNumber objects4 boxes, 4 pointer chasesplus 4 more things for the GC to track
Left: a packed-double array stores raw eight-byte floats inline in one block, read directly. Right: a generic array of the same numbers stores a pointer in each slot, each pointing at its own HeapNumber, so the same four values cost four extra objects and four pointer chases.

The same unboxing can apply to a number stored as a field on an object, when V8 has learned (via the object’s hidden class) that the field always holds a double. Instead of re-boxing on every write, it keeps a single number box for that field and mutates it in place, so a point.x you update every frame does not spawn a fresh object each time.

Try it: which numbers are cheap to store?

Pick a number or type your own. The demo classifies how V8 would store it (an inline Smi, or a boxed HeapNumber double) and whether putting it in a slot allocates. It makes the boundary tangible: small whole numbers are the free case, and stepping across the range or adding a decimal point tips a value onto the heap.

interactiveNumber-representation classifier (illustration of V8 behaviour)

The boundary in that demo is the typical pointer-compressed build. The classification is simulated to make the rule visible; it mirrors how V8 decides, it is not read out of a live engine, and a different build would draw the line in a slightly different place.

BigInt is a different animal

There is one more numeric type, and it is worth a sentence so you do not confuse it with the above. A BigInt is not a faster or bigger number; it is a separate type for arbitrary-precision integers, and it is always heap-allocated. V8 stores a BigInt as a small header plus a sign and an array of 64-bit digit “words”, growing as many words as the value needs.

const big = 9007199254740993n; // the trailing n makes it a BigInt

Because it is never a Smi and never a plain double, a BigInt always carries the object overhead, and its arithmetic works digit by digit rather than in a single CPU instruction. That is the correct trade when you genuinely need integers beyond the safe-integer range of a double, and the wrong one when a normal number would have done. Reach for it deliberately, not as a default.

What this means for your code

Here is the honest calibration, because it is easy to read all this and start micro-managing number types, which is almost always wasted effort.

For ordinary code, do nothing differently. The engine picks representations for you and picks them well. A config value, a total on a page, an id you pass around, the result of one calculation: whether it is a Smi or a HeapNumber is beneath notice, and rewriting code to “keep numbers as Smis” would make it stranger for no measurable gain.

The representation cost becomes real in one narrow shape: a lot of numbers, touched in a hot loop. A big numeric array you scan every frame, an inner loop of a numeric algorithm, a buffer of samples or coordinates. There, two habits pay off, and they are the same habits from elements kinds:

  • Keep a hot numeric array consistently one type. All Smis, or all doubles. A stray float dropped into an integer array, or a stray NaN, Infinity, or null, widens the whole array’s representation and can pull it off the tight, unboxed path.
  • When the data is a fixed block of numbers, use a typed array. A Float64Array or Int32Array has one representation you cannot accidentally change, sidestepping boxing and the elements-kind question entirely.

Everything else is context, not a rule to enforce. Knowing why integer-shaped code is cheap is useful when a profiler points you at hot numeric code. It is not a reason to audit every number in your app.

Summary

  • Every JavaScript number is nominally a 64-bit float, but V8 does not store them all that way. It keeps a couple of physical representations and picks between them, because boxing every integer would make the most common arithmetic needlessly slow.
  • A Smi is a small integer stored inline in the slot, marked by a low tag bit. No allocation, no pointer to follow. On a typical pointer-compressed build a Smi holds a signed ~31-bit integer (roughly plus or minus a billion), and the exact range is build-specific.
  • A number that is fractional or outside Smi range becomes a double, and when it has to live in a slot V8 boxes it as an immutable HeapNumber: an allocation, an indirection, and later some garbage-collection work.
  • In numeric contexts V8 stores unboxed doubles: a packed-double array lays raw 8-byte floats out flat with no boxes, and an always-double object field avoids re-boxing on every write (the exact scheme has changed across V8 versions).
  • BigInt is a separate, always-heap-allocated type (sign plus 64-bit digit words), not a faster number. Use it only when you truly need integers past the double’s safe range.
  • The concept is durable; the tier names, exact Smi range, and object-field scheme are V8-specific and change. SpiderMonkey and JavaScriptCore reach the same goal with NaN-boxing instead of pointer tagging.

What this means for your code: for the numeric arrays and fields you loop over hard, keep them consistently one type (or use a typed array) so they stay on the unboxed path. For every other number in your program, let the engine handle it and write the clearest code.