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 the last bit of a word tells the engine whether the word holds a small whole number or the address of an object on the heap.
- 02Tell what your code sees from how it is storedPredict when V8 stores a number as a Smi and when it needs a HeapNumber box, and say how Firefox's and Safari's engines store values differently.
- 03Use the idea with careConnect whole-number speed, number fields that change in place, and boxing to real measuring habits, instead of rewriting clear code by guesswork.
One word, many values
In JavaScript, 42, 1.5, and NaN all have the same type: number. You never choose how a number is stored. The engine does. The engine is the program that runs your JavaScript. Chrome and Node use one called V8. Firefox uses SpiderMonkey. Safari uses JavaScriptCore.
The engine has to store every value somewhere in memory. Here are the words you need:
- A bit is a single 0 or 1.
- A word is a fixed group of bits that the computer handles in one go. On most computers today, a word is 64 bits.
- The heap is the large area of memory where the engine keeps objects.
- An address (also called a pointer) is a number that says where something sits in memory.
A representation is the way the engine stores one value in those bits. The same JavaScript number can be stored in more than one way. You can't see the difference from your code.
A tagged value is one word that holds two things:
- a few tag bits, which say what kind of thing the word holds;
- the payload, the rest of the bits, which hold the thing itself.
The tag tells the engine one of two things. Either the payload is the value itself, such as a small whole number. Or the payload is an address of an object on the heap.
This lesson comes after on-stack replacement in the engine track. How numbers behave in JavaScript (rounding, safe whole numbers, and so on) is covered in Numbers and Numbers in depth. This lesson is only about storage. You will meet these ideas in order:
- tag bits at the end of a word;
- Smis, small whole numbers stored right in the word;
- HeapNumbers, numbers stored in a box on the heap;
- NaN-boxing, the way Firefox and Safari pack values;
- number fields that the engine updates in place;
- why whole numbers can be faster than numbers with a decimal point.
A contact in your phone can show a phone number right on it. Or it can just say "see note". You can tell at a glance which kind it is. Then you either read the number, or go and look at the note.
- In real life: A phone number is written on the contact
- In JavaScript: A Smi: the number is in the word itself
- In real life: The contact says "see note"
- In JavaScript: A HeapNumber or object kept on the heap
- In real life: You can tell at a glance which kind it is
- In JavaScript: Tag bits say: value here, or address
- In real life: The note tells you where to look
- In JavaScript: The address leads to the object on the heap
Pointer tagging: use the last bit as a label
The engine places every heap object at an address that is a multiple of 4 or 8. This is called alignment. In binary, a multiple of 4 or 8 always ends in 0. So the last bit of a real address is always 0, and it carries no information.
Pointer tagging uses that spare last bit as a label. In V8, the simple version of the rule is:
- last bit
0: the rest of the word is a small whole number; - last bit
1: the rest of the word is an address of an object on the heap.
Real engines add more details, but this one rule is the picture to keep in mind. The code below is a model of it. It doesn't look at real memory. It applies the same rule in plain JavaScript:
Number.isInteger(value)checks that the value is a whole number.2 ** 31means 2 to the power of 31, which is 2147483648.Object.is(value, -0)checks for negative zero,-0. JavaScript has both0and-0, andObject.iscan tell them apart.
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 test for a small whole number. Lines 3 to 5 check that the value is whole and in range. Line 6 turns away -0. Lines 10 to 12 try three values. All three have the type number in JavaScript, but only 42 fits in the word. The other two need a box on the heap.
42 it is 0: the number is right there. For 1.5 it is 1: the word is an address, and the number waits in a box on the heap.V8 calls a number that fits in the word a Smi. It calls the box on the heap a HeapNumber. The next two sections explain each one.
In the playground below, type a number and see how each engine model would store it. It uses two tools:
new ArrayBuffer(8)makes 8 empty bytes (64 bits) of memory.new DataView(...)lets you write a number into those bytes and read the raw bits back.
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 / 10280x5000000000000SmiSmis from -2147483648 to 2147483647. Word 0x0000000000000054, last bit 0.
SmiSmis from -1073741824 to 1073741823. Word 0x00000054, last bit 0.
int320xfffe00000000002a: a whole number, marked with Safari's 0xFFFE tag.
int320xfff900000000002a: a whole number, hidden in a spare NaN pattern.
42 is a Smi in V8 in Node (32-bit Smis) and a Smi in V8 in Chrome (31-bit Smis). Its real 64 bits, in hex, are 0x4045000000000000.
The bits on the first card are real. JavaScript stores every number in a 64-bit format called Float64 (also called a double). Its official name is IEEE-754. The playground shows those bits in hexadecimal, or hex: base 16, where each digit stands for 4 bits. The other cards are models built from the engines' published notes. They are good for thinking, but they don't read your browser's memory.
Small whole numbers: Smis
Smi is short for "small integer". An integer is a whole number. V8 keeps a Smi right in the tagged word, so it doesn't need to make a separate box on the heap. Counters, array positions, and lengths are usually Smis. That is why engine teams care so much about them.
How small is "small"? We can check in Node. Node has a special command, %IsSmi(value), that asks V8 directly. It only works when you start Node with the flag --allow-natives-syntax, so you can't run this code in the browser:
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));This lesson's tests run that code in a real Node process. They check that:
- Node is version
v22.xwith V812.4.x; - pointer compression and the V8 sandbox are both off (pointer compression is explained just below);
2 ** 31 - 1is a Smi, but2 ** 31is not;-0and1.5are not Smis.
Chrome is different. Chrome uses pointer compression: it stores addresses in 32 bits instead of 64, to save memory. That leaves 31 bits for a Smi. So in 64-bit Chrome, Smis go from -(2 ** 30) to 2 ** 30 - 1. That is about one billion either way. The Node build tested here has a bigger range, about two billion either way. You can read more in The heap & pointer compression.
The code below makes the same Smi decision as the Node test, using only normal JavaScript. You can run it in the browser:
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));Lines 2 and 3 set the smallest and largest Smi. Line 7 turns away -0. Lines 12 to 15 try four values. Only the first one, 2 ** 31 - 1, is a Smi.
Now watch one variable grow past the largest Smi. Step through it:
Watch x = x + 1 reach the largest Smi in Node, stay a Smi once more, and then need 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. Use the Node range (32-bit Smis) and the Chrome range (31-bit Smis) from this lesson.
HeapNumbers and boxing
A HeapNumber is a small object on V8's heap that holds one Float64 number. V8 uses it for every number that can't be a Smi:
- numbers with a decimal point, like
1.5; Infinity,-Infinity, andNaN;-0;- whole numbers outside the Smi range.
Boxing means putting a value in a box like this on the heap. The tagged word then holds the box's address instead of the value.
Why can't -0 be a Smi? Look at its bits. The code below writes a number into 8 bytes and prints them in hex:
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 writes the number into the 8 bytes. Line 7 prints the bits of 0.1. Binary can't write 0.1 exactly, so the bits repeat (the many 9s in the hex) and are rounded at the end. Line 8 prints the bits of -0. Only the first bit, the sign bit, is 1. For +0 every bit is 0. A Smi stores a whole number with no room for that extra sign, so -0 would turn into 0. Line 9 prints the usual bits of NaN.
Node can also show what V8 made for a value. %DebugPrint(value) prints V8's own description. Like %IsSmi, it only works with --allow-natives-syntax:
%DebugPrint(1.5);const sample = { x: 1.5 };%DebugPrint(sample);sample.x = sample.x + 1;%DebugPrint(sample);The test reads the printed text and checks two things. First, 1.5 prints as a HeapNumber. Second, the field x of the object is stored inside the object, and its value is a HeapNumber, both before and after the update. The test does not check addresses, because they change on every run.
The table below lists the ways of storing numbers in this lesson. Read each row from left to right: the name, what it holds, and where you meet it.
| Name | What does it hold? | When is it used? |
|---|---|---|
| V8 Smi | A small whole number, kept right in the word. | Counters, array positions, lengths, and other small whole numbers. |
| V8 HeapNumber | A box on the heap that holds one 64-bit number. The word holds the box's address. | Numbers with a decimal point, NaN, Infinity, -0, and whole numbers that are too big for a Smi. |
| NaN-boxed word (Firefox, Safari) | A 64-bit word. Normal numbers sit right in it; other values hide in spare NaN bit patterns. | In engines that want to keep most numbers out of boxes. |
| MutableHeapNumber field | A V8 box for an object field that holds numbers with a decimal point, which V8 may change in place. | When the same number field is updated again and again, so V8 doesn't make a new box each time. |
NaN-boxing in Firefox and Safari
V8 uses a tag bit and HeapNumbers. SpiderMonkey (Firefox) and JavaScriptCore (Safari) use a different trick, called NaN-boxing. It works like this:
- Every value is one 64-bit word.
- A normal Float64 number is stored right in the word, with no box.
- The Float64 format has a huge number of bit patterns that all mean
NaN. JavaScript only needs one of them. - The engine uses the spare
NaNpatterns for everything else: whole numbers,true, addresses of objects, and so on.
Your code sees exactly the same values in every engine. Only the hidden storage is different. The block below summarizes what the engines' own source code says:
SpiderMonkey JS::Value source comment (Firefox): Any NaN bit pattern means either the real NaN or a value that is not a number. In PUNBOX64, the tag sits in five bits near the top of the word. JavaScriptCore JSCJSValue.h source comment (Safari): 0xFFFE marks 32-bit whole numbers. 0x0000 marks an address or another simple value. Numbers are stored by adding 2^49 to their bits.In plain words, line by line:
- Line 1 names the file in Firefox's engine.
- Line 2: any
NaNbit pattern means either the realNaNor a value that is not a number. - Line 3: in the 64-bit layout (called PUNBOX64), the tag sits in five bits near the top of the word.
- Line 5 names the file in Safari's engine.
- Line 6: a word that starts with
0xFFFEholds a 32-bit whole number. - Line 7: a word that starts with
0x0000holds an address or another simple value. - Line 8: numbers are stored after adding a fixed amount,
2^49, to their bits.
You can read the original notes in Mozilla's Value.h and WebKit's JSCJSValue.h.
NaN-boxing is not part of the JavaScript language. It is just one way to store values. Every engine gives the same answer for 0.1 + 0.2. Numbers in depth covers rounding, safe whole numbers, and arithmetic. This section only shows how engines can pack values.
Number fields that change in place
A normal HeapNumber never changes. Two variables might point at the same box holding 4.2. If V8 changed the box, both variables would change, and that would break JavaScript. So when a number changes, V8 makes a new box.
Making a new box every time is slow when an object field changes again and again. So V8 has a shortcut. When a field always holds numbers with a decimal point, V8 can mark it as a double field. It then keeps that field's number in a box that it is allowed to change, called a MutableHeapNumber, and updates the box in place. The V8 team explains this in its blog post about a React slowdown.
Step through the code below. It shows what your code sees:
Step through a number field that changes. The steps show what your code sees; the text explains V8's hidden in-place update.
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);saved read 1.1 before line 10 ran. Line 10 changed the field in the object to 2.1. The copy in saved did not change.saved keeps the number it read. Changing point.x later does not change it. V8's shortcut is safe only because of one rule: when a number is read out of a double field, V8 copies it into a new normal box. So saved never shares the box that changes.
Where does each fact come from? The %DebugPrint code in the previous section shows the double field as a HeapNumber. The V8 blog gives the name MutableHeapNumber and explains the update in place.
Why whole numbers can be faster
A Smi lives right in the word. So working with Smis can skip some costs:
- The engine doesn't need to make a box on the heap.
- The garbage collector, the part of the engine that clears away boxes nobody uses, has less to clean up.
- The engine can use fast whole-number instructions instead of decimal-point ones.
That doesn't make doubles bad. JavaScript needs them, and they are correct for many values. It just means that busy code full of small whole numbers is cheaper for the engine to run.
function addOne(value) { return value + 1;} console.log(Number.isInteger(addOne(41)));console.log(addOne(2 ** 31 - 1));console.log(addOne(1.5));Line 1 defines addOne, which adds 1. Line 5 gives 42, a whole number, so it prints true. Line 6 gives 2147483648. That is still a whole number, but it is past the largest Smi in Node, so it needs a HeapNumber. Line 7 gives 2.5, which has a decimal point, so a double is the natural fit. Optimizing compilers make more choices like these. They are covered in What optimizing compilers do.
Practical use: measure first, then keep busy numbers steady
Most of the time, you should not try to make numbers into Smis by hand. The useful habits are small. They matter only for hot code: code that runs very many times, such as a chart animation, a parser, or a big grid. A profiler, like the Performance panel in your browser's developer tools, shows you which code is hot.
- Turn user input into numbers as soon as it arrives. Then busy math always gets numbers, never sometimes strings.
- Don't let
-0,NaN, or values like0.5slip into counters that should be whole numbers. - Write clear arrays and objects. Let the engine choose Smi, double, or HeapNumber.
- Profile first. Then try a change with real data and measure again.
At a post office, a label tells staff how to handle a package before they open it. A small one stays on the counter. A big one goes to a shelf. The engine makes the same kind of quick choice from the tag bits.
- In real life: A small package stays on the counter
- In JavaScript: A Smi: the whole number is in the word
- In real life: A big package gets a label with a shelf number
- In JavaScript: An address: go to the heap to find the value
- In real life: The label changes how staff handle it, not whose it is
- In JavaScript: Storage changes the engine's work, not what your code sees
Common misconceptions
- "A Smi is a JavaScript type." No. It is one way V8 stores numbers.
typeof 1still says"number". - "Node and Chrome always have the same Smi range." No. The Node build tested here has pointer compression off. Chrome has it on, so its Smis are smaller.
- "A HeapNumber is the same as
new Number(...)." No. A HeapNumber is hidden inside V8.new Number(1.5)makes a normal object that your code can see. - "NaN-boxing changes how math works." No. It only changes storage. The JavaScript rules still decide every answer.
- "Code with whole numbers is always faster." No. It depends on how often the code runs, what the engine has learned about it, and what you measure.
The table below lists ideas that are easy to mix up. Read each row as: the idea, what it really is, and what it is not.
| Idea | What is it? | What is it not? |
|---|---|---|
JavaScript Number | The number type in the JavaScript rules. | How the engine stores the number right now: a Smi, a HeapNumber, or a NaN-boxed word. |
| Smi | One way V8 stores small whole numbers. | A new JavaScript type. typeof 1 is still "number". |
| HeapNumber | A hidden V8 box on the heap that holds one 64-bit number. | The object you get from new Number(1.5), which your code can see. |
| Pointer compression | A V8 way to save memory by storing addresses in 32 bits. In Chrome it leaves 31 bits for a Smi. | Rounding in JavaScript math, or the largest safe whole number, Number.MAX_SAFE_INTEGER. |
Practice exercises
6 EXERCISESWhat does the first console.log print 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 line prints HeapNumber, because 2 ** 31 is one past the largest Smi. The second line also prints HeapNumber, because -0 can't be a Smi.
Chrome uses pointer compression. Is 2 ** 30 a Smi or a HeapNumber there?
In Chrome's 31-bit range, 2 ** 30 is a HeapNumber. In the Node range tested here, it is still a Smi.
Run or read the starter code. What hex text 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"));It prints 0x8000000000000000: the sign bit is 1 and every other bit is 0. A Smi has no room for that sign, so it can't store -0.
This helper returns true for values that are not Smis. Name one check that is missing.
function maybeSmi(value) {
return Number.isInteger(value);
}
console.log(maybeSmi(2 ** 40));
console.log(maybeSmi(-0));The function also needs a range check and an Object.is(value, -0) check. 2 ** 40 and -0 are whole numbers, but neither is a Smi.
In JavaScriptCore, which tag marks a 32-bit whole number?
Safari's JavaScriptCore marks a 32-bit whole number with 0xFFFE at the start of the word.
Your chart updates thousands of points in every frame. What should you do first, before you rewrite any code to get more Smis?
Measure, or profile, first. If the busy code mixes numbers with strings, NaN, or stray decimals, turn the input into clean numbers where it arrives. Then measure again to see if it got faster.
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 whole numbers be faster than numbers with a decimal point?
Choose an answer to see the explanation.
Question 8 of 8What is the safest rule to follow in real projects?
Choose an answer to see the explanation.
Key takeaways
- A tagged value is one word with tag bits and a payload. The tag says whether the payload is the value or an address.
- A V8 Smi stores a small whole number right in the word. A HeapNumber stores any other number in a box on the heap.
- The Node 22 build tested here has 32-bit Smis, because pointer compression is off. Chrome has pointer compression on, so it has 31-bit Smis.
- Firefox's and Safari's engines hide other values inside spare
NaNbit patterns instead. - Use this knowledge only after a profiler shows busy number code.
Remember the one-liner.
JavaScript gives you values; the engine picks the cheapest way to store each one, and your code sees the same result either way.
Up next: Booleans inside the engine. There, true and false turn out to be two ready-made objects on the heap that never change. After that come undefined, null, and strings.