Tagged values, Smis & heap numbers
Learn how engines pack JavaScript values into one word with pointer tags, Smis, HeapNumbers, NaN-boxing, and measurement-friendly advice.
- 01Read a tagged wordExplain how low tag bits let one machine word mean either an immediate small integer or a pointer to a heap object.
- 02Separate number semantics from storagePredict when V8 can use Smis, when it needs HeapNumbers, and why SpiderMonkey and JavaScriptCore can NaN-box values differently.
- 03Use the idea responsiblyConnect integer fast paths, mutable double fields, boxing, and representation changes to real profiling habits without rewriting clear code blindly.
One word, many values
JavaScript gives you one public type for ordinary numbers: number. Engines still need a private storage plan for values that might be a small integer, a double, an object pointer, undefined, or a string. A representation is that private storage shape.
A tagged value is one machine word that carries both a payload and a few tag bits, so the engine can tell whether the word is an immediate value such as a Smi or a pointer to a heap object.
This lesson follows on-stack replacement in the engine track. It links back to Numbers and Numbers in depth for IEEE-754 semantics. We focus on storage strategies: pointer tagging, Smis, HeapNumbers, NaN-boxing, mutable heap numbers, and why integer paths can be cheaper than double paths.
A phone contact can show a number directly or say “see note.” The label tells you whether the value is right there or needs another place.
- In real life: A number appears on the contact
- In JavaScript: A Smi stored directly in the tagged word
- In real life: The contact says see note
- In JavaScript: A HeapNumber or object stored on the heap
- In real life: The label says number or note
- In JavaScript: Tag bits say immediate value or pointer
- In real life: The note tells you where to look
- In JavaScript: A pointer leads to the heap object
Where the analogy stops: A phone app reads labels for people. A JavaScript engine reads tag bits with machine instructions.
Pointer tagging: use the quiet low bits
Heap objects are aligned in memory, so their addresses end with a few zero bits. Pointer tagging uses those otherwise quiet bits as labels. In V8's mental model, a low bit of 0 can mean a small integer payload, while a low bit of 1 can mean a pointer-like heap reference. Real layouts have more details; the low-bit rule is the useful first picture.
function teachingTag(value) { const isSmi = Number.isInteger(value) && value >= -(2 ** 31) && value <= 2 ** 31 - 1 && !Object.is(value, -0); return isSmi ? "low bit 0: Smi payload" : "low bit 1: pointer to a box";} console.log(teachingTag(42));console.log(teachingTag(1.5));console.log(teachingTag(-0));Line 2 starts the Smi test. Line 6 rejects -0. Lines 9 through 11 show three values with the same public JavaScript type, but different private storage possibilities.
const input = "42";const value = Number(input);const view = new DataView(new ArrayBuffer(8));view.setFloat64(0, value, false); console.log("double bits", view.getBigUint64(0, false).toString(16));console.log("Node model", Number.isInteger(value) ? "Smi candidate" : "HeapNumber");0x40450000000000000 / 10280x5000000000000SmiRange -2147483648 to 2147483647; word 0x0000000000000054; tag 0.
SmiRange -1073741824 to 1073741823; word 0x00000054; tag 0.
int320xfffe00000000002a. teaching model based on JSC's 0xFFFE int32 tag.
int320xfff900000000002a. teaching tag for a non-double JS::Value payload.
42 is Smi in the proven Node 64-bit model and Smi in the Chrome pointer-compression model. The Float64 bits are 0x4045000000000000.
The playground computes the real IEEE-754 bits with DataView. The tagged words and NaN-boxed words are teaching models built from documented layouts. They are useful for reasoning, but they are not a browser memory inspector.
Small integers: Smis
A Smi is V8's “small integer” representation. It keeps an integer directly in the tagged value instead of allocating a separate number object. That is why counters, array indexes, and loop lengths are such important cases for engine teams.
%IsSmiJavaScriptconsole.log("node", process.version);console.log("v8", process.versions.v8);console.log("pointer compression", process.config.variables.v8_enable_pointer_compression);console.log("sandbox", process.config.variables.v8_enable_sandbox);console.log("max smi", %IsSmi(2 ** 31 - 1));console.log("overflow", %IsSmi(2 ** 31));console.log("min smi", %IsSmi(-(2 ** 31)));console.log("underflow", %IsSmi(-(2 ** 31) - 1));console.log("minus zero", %IsSmi(-0));console.log("double", %IsSmi(1.5));The tests run this probe in a child Node process with --allow-natives-syntax. They prove Node is v22.x with V8 12.4.x, pointer compression and the V8 sandbox are off, 2 ** 31 - 1 is a Smi, 2 ** 31 is not, and both -0 and 1.5 are not Smis.
Chrome differs. V8's pointer-compression article explains that compressed tagged values use a 31-bit Smi payload. On 64-bit Chrome, that means the usual Smi range is -(2 ** 30) through 2 ** 30 - 1. This lesson proves the Node side locally and links the compressed-heap details to The heap & pointer compression.
function nodeSmiKind(value) { const min = -(2 ** 31); const max = 2 ** 31 - 1; return Number.isInteger(value) && value >= min && value <= max && !Object.is(value, -0) ? "Smi" : "HeapNumber";} console.log(nodeSmiKind(2 ** 31 - 1));console.log(nodeSmiKind(2 ** 31));console.log(nodeSmiKind(-0));console.log(nodeSmiKind(1.5));Follow x = x + 1 as it approaches the proven Node Smi limit, stays immediate once, then needs a HeapNumber.
script
function nodeSmiKind(value) { return Number.isInteger(value) && value >= -(2 ** 31) && value <= 2 ** 31 - 1 && !Object.is(value, -0) ? "Smi" : "HeapNumber";} console.log(x, nodeSmiKind(x));x = x + 1;console.log(x, nodeSmiKind(x));x = x + 1;console.log(x, nodeSmiKind(x));422 ** 30 - 12 ** 302 ** 31 - 11.5-0NaN
Sort each value by the Node and Chrome teaching models used in this lesson.
HeapNumbers and boxing
A HeapNumber is V8's heap object for number values that do not fit a Smi representation. Fractional numbers, infinities, NaN, -0, and out-of-range integers need Float64 storage. Boxing means putting the numeric payload in a heap object and carrying a pointer to that box.
function float64Hex(value) { const view = new DataView(new ArrayBuffer(8)); view.setFloat64(0, value, false); return "0x" + view.getBigUint64(0, false).toString(16).padStart(16, "0");} console.log(float64Hex(0.1));console.log(float64Hex(-0));console.log(float64Hex(Number.NaN));Line 3 stores a number into an eight-byte buffer. Line 6 prints 0.1's famous repeating-binary approximation. Line 7 shows why -0 needs special storage: its bits differ from +0.
%DebugPrint HeapNumber evidenceJavaScript%DebugPrint(1.5);const sample = { x: 1.5 };%DebugPrint(sample);sample.x = sample.x + 1;%DebugPrint(sample);The test asserts stable text from %DebugPrint: 1.5 prints as HeapNumber, and an object field holding 1.5 is an in-object field whose value is a HeapNumber. It does not assert addresses or exact maps.
| Representation | What it stores | Where you meet it |
|---|---|---|
| V8 Smi | A small signed integer stored directly in the tagged value. | Fast counters, indexes, lengths, and bitwise-friendly arithmetic. |
| V8 HeapNumber | A heap object that stores a 64-bit floating-point number. | Doubles, NaN, Infinity, -0, and integers outside the Smi range. |
| JSC / SpiderMonkey NaN-box | A 64-bit word where double bits are direct and non-number values hide in NaN payload space. | Engines that choose to keep most doubles unboxed in the value word. |
| MutableHeapNumber field | A V8 optimization for object fields represented as doubles. | Repeated updates to a double property without allocating a fresh box each time. |
NaN-boxing in SpiderMonkey and JavaScriptCore
V8 uses pointer tagging plus HeapNumbers. SpiderMonkey and JavaScriptCore have used NaN-boxing: a 64-bit value word stores real doubles directly, while non-number values hide in IEEE-754 NaN payload patterns. JavaScript still sees the same values; only the engine's private layout changes.
SpiderMonkey JS::Value source comment: Any IEEE NaN bitstring represents either ECMAScript NaN or a non-number value. In PUNBOX64, a tag lives in the five most significant fraction bits. JavaScriptCore JSCJSValue.h source comment: 0xFFFE marks 32-bit signed integers. 0x0000 denotes a pointer or another immediate. Doubles are encoded by adding 2^49 to the double bits.Mozilla's Value.h says JS::Value uses IEEE NaN space for non-number values and describes PUNBOX64 tags. WebKit's JSCJSValue.h describes its NaN-encoded immediates, including 0xFFFE for 32-bit integers and an offset for double values.
NaN-boxing is not JavaScript's number model. It is an implementation strategy. Numbers in depth covers precision, safe integers, and arithmetic semantics; this section only explains how engines can pack values.
Mutable heap numbers for double fields
Ordinary HeapNumbers are immutable. If two places point at the same boxed 4.2, changing one JavaScript property must not secretly change the other value. V8's React performance-cliff article explains the optimization: when a field is represented as a double field, V8 can use a MutableHeapNumber internally and update that field in place.
Step through a double field update. The replay shows JavaScript semantics; the V8 blog explains the internal MutableHeapNumber optimization.
script
function nudgeX(object) { object.x = object.x + 1; return object.x;} console.log(nudgeX(point));const saved = point.x;point.x = point.x + 1;console.log(saved);console.log(point.x);The replay shows the JavaScript rule: saved keeps the number it read. The internal optimization is allowed only because V8 re-boxes the value when it escapes the object field. The Node debug probe in the previous section lets us see a double field as a HeapNumber, while the V8 blog supplies the name MutableHeapNumber and the in-place update explanation.
Why integers can be faster than doubles
A Smi can avoid allocation, garbage-collector work, and some floating-point machinery. It can also feed integer-specific compiled code. That does not make doubles bad; JavaScript needs doubles for its language semantics. It means a hot path full of predictable small integers gives the engine a cheaper representation to work with.
function addOne(value) { return value + 1;} console.log(Number.isInteger(addOne(41)));console.log(addOne(2 ** 31 - 1));console.log(addOne(1.5));Line 5 stays an integer result. Line 6 crosses the proven Node Smi ceiling even though the mathematical result is still an integer. Line 7 uses a fractional input, so a double representation is the natural fit. Optimizing compilers make deeper representation choices; link that to What optimizing compilers do instead of duplicating it here.
Practical use: measure, then keep hot values stable
Most developers should not chase Smis by hand. The practical habit is smaller: keep hot numeric code consistent when it is natural, avoid mixing strings and numbers in a tight arithmetic site, and measure before changing readable code. If a chart animation, parser, or grid renderer is hot, this lesson gives you vocabulary for the profile.
- Parse user input at the edge, so hot math receives numbers instead of sometimes strings.
- Do not use
-0,NaN, or fractional sentinel values accidentally in integer counters. - Prefer clear arrays and object fields; let the engine choose Smi, double, or heap storage.
- Profile first, then test a representation-friendly change with real data.
A package label tells you how to handle it before opening it. Engines make a similar quick first decision from tag bits.
- In real life: A small package stays on the counter
- In JavaScript: Smi: the integer is in the word
- In real life: A large package has a location label
- In JavaScript: Pointer: follow the word to heap storage
- In real life: A label changes handling, not ownership
- In JavaScript: A representation changes engine work, not JavaScript meaning
Where the analogy stops: Package labels are visible. Engine tags are invisible implementation details that can change between engines or versions.
Common misconceptions
- “A Smi is a JavaScript type.” No. It is a V8 representation.
typeof 1still says"number". - “Node and Chrome always have the same Smi range.” No. The official Node build tested here has pointer compression off; Chrome uses pointer compression.
- “HeapNumber means `new Number(...)`.” No. HeapNumber is an internal V8 object;
new Numbercreates a public wrapper object. - “NaN-boxing changes arithmetic.” No. It changes storage. ECMAScript semantics still define the observable answer.
- “Integer-looking code is always faster.” No. It depends on hotness, feedback, compiler choices, and real measurements.
| Idea | What it means | Do not confuse it with |
|---|---|---|
JavaScript Number | The language type defined by ECMAScript. | Whether the engine currently stores this value as a Smi, HeapNumber, or NaN-boxed payload. |
| Smi | An engine representation for small signed integers. | A new JavaScript type. typeof 1 is still "number". |
| HeapNumber | A V8 heap object containing a Float64. | A boxed Number object from new Number(1.5). |
| Pointer compression | A V8 heap-layout technique that stores compressed pointers and uses 31-bit Smis in Chrome. | IEEE-754 precision or the public range of safe JavaScript integers. |
Practice exercises
6 EXERCISESWhat classification does the first log produce for 2 ** 31?
function nodeSmiKind(value) {
return Number.isInteger(value) &&
value >= -(2 ** 31) &&
value <= 2 ** 31 - 1 &&
!Object.is(value, -0)
? "Smi"
: "HeapNumber";
}
console.log(nodeSmiKind(2 ** 31));
console.log(nodeSmiKind(-0));The first logged classification is HeapNumber, and the second is also HeapNumber because -0 cannot be a Smi.
In the Chrome pointer-compression model, how should 2 ** 30 be sorted?
2 ** 30 is a HeapNumber in the Chrome 31-bit model, while it is still a Smi in the Node model tested here.
-0 bit checkRun or trace the starter code. What hex string does it print?
const view = new DataView(new ArrayBuffer(8));
view.setFloat64(0, -0, false);
console.log("0x" + view.getBigUint64(0, false).toString(16).padStart(16, "0"));const view = new DataView(new ArrayBuffer(8));
view.setFloat64(0, -0, false);
console.log("0x" + view.getBigUint64(0, false).toString(16).padStart(16, "0"));The program prints 0x8000000000000000. That is why a Smi payload cannot represent -0 faithfully.
The fake helper returns true for values this lesson would not model as Smis. Name one missing check.
function maybeSmi(value) {
return Number.isInteger(value);
}
console.log(maybeSmi(2 ** 40));
console.log(maybeSmi(-0));The function also needs a Smi range check and an Object.is(value, -0) rejection. 2 ** 40 and -0 are integers but not Smis in this model.
Which tag did the lesson name for JavaScriptCore 32-bit integer values?
JavaScriptCore's source comment describes 0xFFFE as the tag for 32-bit signed integers in this layout.
Your chart updates thousands of points per frame. What is the first thing you should do before rewriting code for Smi-friendly values?
Measure or profile first. If a real hot path mixes numbers, strings, NaN, or fractional sentinel values, normalize at the boundary and check whether the measured path improves.
Quiz: check your understanding
8 QUESTIONSQuestion 1 of 8What is pointer tagging?
Choose an answer to see the explanation.
Question 2 of 8What does this Node Smi model print?
Read the code, then predictfunction nodeSmiKind(value) { return Number.isInteger(value) && value <= 2 ** 31 - 1 && value >= -(2 ** 31) && !Object.is(value, -0) ? "Smi" : "HeapNumber"; } console.log(nodeSmiKind(2 ** 31 - 1)); console.log(nodeSmiKind(2 ** 31));Choose an answer to see the explanation.
Question 3 of 8Why is
-0not a V8 Smi?Choose an answer to see the explanation.
Question 4 of 8What does this Float64 bit program print?
Read the code, then predictconst view = new DataView(new ArrayBuffer(8)); view.setFloat64(0, -0, false); console.log("0x" + view.getBigUint64(0, false).toString(16).padStart(16, "0"));Choose an answer to see the explanation.
Question 5 of 8What does NaN-boxing use?
Choose an answer to see the explanation.
Question 6 of 8What does this copy example print?
Read the code, then predictconst point = { x: 0.1 }; const saved = point.x; point.x = point.x + 1; console.log(saved); console.log(point.x);Choose an answer to see the explanation.
Question 7 of 8Why can integers be faster than doubles?
Choose an answer to see the explanation.
Question 8 of 8What is the safest production rule from this lesson?
Choose an answer to see the explanation.
Key takeaways
- A tagged value uses payload bits plus tag bits so one word can represent many JavaScript values.
- V8 Smis store small integers directly; HeapNumbers store doubles and special number values on the heap.
- This Node 22/V8 12.4 build proves 32-bit Smis because pointer compression is off; Chrome's compressed model uses 31-bit Smis.
- SpiderMonkey and JavaScriptCore use NaN payload space to pack values in a different way.
- Representation knowledge is practical only after profiling shows a hot numeric path.
Remember the one-liner.
JavaScript gives you values; engines choose the cheapest private representation that still preserves the same observable behavior.
Up next: Booleans inside the engine, where true and false turn out to be two pre-built read-only heap objects, followed by undefined, null, and strings.