cf.completefrontendCode editorOpen lab
THE JAVASCRIPT FIELD GUIDE

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.

By the end, you can
  • 01
    Read a tagged wordExplain how low tag bits let one machine word mean either an immediate small integer or a pointer to a heap object.
  • 02
    Separate number semantics from storagePredict when V8 can use Smis, when it needs HeapNumbers, and why SpiderMonkey and JavaScriptCore can NaN-box values differently.
  • 03
    Use 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.

Definition

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.

Real-life analogyA phone contact with a note

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.

A low-bit teaching modelPop out in the code editor (opens in a new tab)JavaScript
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.

Playground: tag one number
Browser-safe bit inspectorPop out in the code editor (opens in a new tab)JavaScript
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");
Computed model42
Float64 bits0x4045000000000000
sign / exponent0 / 1028
fraction0x5000000000000
Node V8 modelSmi

Range -2147483648 to 2147483647; word 0x0000000000000054; tag 0.

Chrome V8 modelSmi

Range -1073741824 to 1073741823; word 0x00000054; tag 0.

JSC NaN-box modelint32

0xfffe00000000002a. teaching model based on JSC's 0xFFFE int32 tag.

SpiderMonkey modelint32

0xfff900000000002a. teaching tag for a non-double JS::Value payload.

Try it yourself

42 is Smi in the proven Node 64-bit model and Smi in the Chrome pointer-compression model. The Float64 bits are 0x4045000000000000.

This is a teaching model. It computes IEEE-754 bits in your browser and models V8/JSC/SpiderMonkey tagging from documented layouts; it does not inspect your browser's engine.

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.

Node-only proof with %IsSmiJavaScript
console.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));
Verified runtime fact

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.

Browser-safe Smi classifier matching the Node proofPop out in the code editor (opens in a new tab)JavaScript
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));
Step through x = x + 1 at the Smi ceiling
Step 0 of 6Ready
Your turn: follow the blue line

Follow x = x + 1 as it approaches the proven Node Smi limit, stays immediate once, then needs a HeapNumber.

Running in
  1. script
Next: line 10
Click the blue line to take the next stepPop out in the code editor (opens in a new tab)JavaScript
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));
CallStoreChangeResultRun = next line. Ran = already executed.
Recent returnsNothing yet. Start with the blue line.
A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.
Smi or HeapNumber?
  • 42
  • 2 ** 30 - 1
  • 2 ** 30
  • 2 ** 31 - 1
  • 1.5
  • -0
  • NaN
Try it yourself
0 of 7 correct

Sort each value by the Node and Chrome teaching models used in this lesson.

Choose a category for every card. You can change an answer at any time; Reset clears them all.

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.

Inspect the real Float64 bitsPop out in the code editor (opens in a new tab)JavaScript
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.

Node-only %DebugPrint HeapNumber evidenceJavaScript
%DebugPrint(1.5);const sample = { x: 1.5 };%DebugPrint(sample);sample.x = sample.x + 1;%DebugPrint(sample);
What the debug output proves

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.

Common value representations
RepresentationWhat it storesWhere you meet it
V8 SmiA small signed integer stored directly in the tagged value.Fast counters, indexes, lengths, and bitwise-friendly arithmetic.
V8 HeapNumberA heap object that stores a 64-bit floating-point number.Doubles, NaN, Infinity, -0, and integers outside the Smi range.
JSC / SpiderMonkey NaN-boxA 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 fieldA 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.

Primary-source layout notes, simplifiedText
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.

Same semantics, different boxes

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
Step 0 of 7Ready
Your turn: follow the blue line

Step through a double field update. The replay shows JavaScript semantics; the V8 blog explains the internal MutableHeapNumber optimization.

Running in
  1. script
Next: line 1
Click the blue line to take the next stepPop out in the code editor (opens in a new tab)JavaScript
 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);
CallStoreChangeResultRun = next line. Ran = already executed.
Recent returnsNothing yet. Start with the blue line.
A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.

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.

Integer path, overflow, and double pathPop out in the code editor (opens in a new tab)JavaScript
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.
Real-life analogyA label changes how a package is handled

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 1 still 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 Number creates 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.
Ideas that are easy to mix up
IdeaWhat it meansDo not confuse it with
JavaScript NumberThe language type defined by ECMAScript.Whether the engine currently stores this value as a Smi, HeapNumber, or NaN-boxed payload.
SmiAn engine representation for small signed integers.A new JavaScript type. typeof 1 is still "number".
HeapNumberA V8 heap object containing a Float64.A boxed Number object from new Number(1.5).
Pointer compressionA 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 EXERCISES
Exercise 1 · Warm-upPredict the Node overflow

What classification does the first log produce for 2 ** 31?

Starter codePop out in the code editor (opens in a new tab)JavaScript
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));

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · Warm-upUse the Chrome 31-bit model

    In the Chrome pointer-compression model, how should 2 ** 30 be sorted?

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeWrite the -0 bit check

      Run or trace the starter code. What hex string does it print?

      Starter codePop out in the code editor (opens in a new tab)JavaScript
      const view = new DataView(new ArrayBuffer(8));
      view.setFloat64(0, -0, false);
      console.log("0x" + view.getBigUint64(0, false).toString(16).padStart(16, "0"));

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticeFind the bug in a fake Smi check

        The fake helper returns true for values this lesson would not model as Smis. Name one missing check.

        Starter codePop out in the code editor (opens in a new tab)JavaScript
        function maybeSmi(value) {
          return Number.isInteger(value);
        }
        
        console.log(maybeSmi(2 ** 40));
        console.log(maybeSmi(-0));

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · PracticeRead the NaN-box tag

          Which tag did the lesson name for JavaScriptCore 32-bit integer values?

          Answer, then press Check. Spacing and letter case don’t matter.

            Exercise 6 · ChallengeApply it to a chart or grid

            Your chart updates thousands of points per frame. What is the first thing you should do before rewriting code for Smi-friendly values?

            Answer, then press Check. Spacing and letter case don’t matter.

              Quiz: check your understanding

              8 QUESTIONS
              Value representation quiz · 8 questionsScore: first tries count
              1. Question 1 of 8What is pointer tagging?

                Choose an answer to see the explanation.

              2. Question 2 of 8What does this Node Smi model print?

                Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
                function 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.

              3. Question 3 of 8Why is -0 not a V8 Smi?

                Choose an answer to see the explanation.

              4. Question 4 of 8What does this Float64 bit program print?

                Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
                const 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.

              5. Question 5 of 8What does NaN-boxing use?

                Choose an answer to see the explanation.

              6. Question 6 of 8What does this copy example print?

                Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
                const 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.

              7. Question 7 of 8Why can integers be faster than doubles?

                Choose an answer to see the explanation.

              8. 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.

              CompleteFrontend Clear concepts. Working examples.