22. How JavaScript stores numbers and objects
Every JavaScript number is a 64-bit double, yet V8 often stores it as something else entirely. Learn the tricks, and you’ll see why one TypeScript line can make a loop several times slower.
- Describe the IEEE 754 double format that every JavaScript number follows.
- Explain how V8 stores small integers (Smis) and other numbers (HeapNumbers).
- Sketch what an object looks like in memory, header first.
- Explain field representations, and why TypeScript class field declarations slowed down this book’s benchmark.
Two classes that look the same
Here are two TypeScript classes for a particle. Read them carefully:
class P {
x: number;
constructor(x: number) {
this.x = x;
}
}
class K {
declare x: number;
constructor(x: number) {
this.x = x;
}
}
They hold the same data and run the same constructor. Yet in this book’s benchmarks, moving particles made from the first class was several times slower. By the end of this chapter you’ll know exactly why, and it starts with how a single number is stored.
A JavaScript engine is like a warehouse that promises every parcel looks the same from the outside. Behind the counter, though, it uses whatever packing is cheapest: a small item goes straight into a pigeonhole, and a bulky one goes on a shelf in the back with a ticket in the pigeonhole saying where to find it.
Every number is a double
The language says every JavaScript number is a 64-bit floating-point
value in the IEEE 754 double format: 1 sign bit, 11 exponent bits and
52 mantissa bits[1]. You met these as float64 in
chapter 16.
IEEE 754 is the standard format for floating-point numbers. A double uses 64 bits: a sign bit, 11 exponent bits (a power of two) and 52 mantissa bits (the significant digits). Integers are exact only up to 253 − 1[1].
But the language only fixes what numbers behave like. An engine may store them however it likes, as long as nobody can tell the difference[2].
Small integers live in the slot
Loop counters, array indexes and lengths are small whole numbers, and they are everywhere. Wrapping each one in a 64-bit double on the heap would be wasteful, so V8 uses a trick called pointer tagging: a slot that normally holds an address can hold a small integer instead, and the lowest bit says which one it is[3].
let a = 42;let b = 4.2;b = b + 1;let c = 2 ** 30;
A Smi (small integer) is an integer stored directly in a
pointer-sized slot, marked by a tag bit of 0. Anything that isn’t a Smi is
a pointer to a heap object. A HeapNumber is a heap object that holds
one double; V8 uses it for numbers that aren’t Smis, like 4.2, -0,
NaN or numbers that are too big[2]. HeapNumbers
are immutable, so changing the value means allocating a new
one[2].
How big a Smi can be depends on how V8 was built. With pointer compression, which the V8 team built to save memory in Chrome, slots are 32 bits and a Smi carries 31 bits including its sign. A 64-bit build without it uses 32-bit Smi payloads[3].
What an object looks like
An object is a block of memory too. V8’s blog describes its start: the first word points to the object’s hidden class (its shape: which properties it has and where they sit), followed by pointers to a store for extra properties and a store for indexed elements. After that come the in-object properties, the fastest kind[4].
So a particle { x, y, vx, vy, mass, health } is not six numbers in a
row. It is a header, then six slots, and each slot holding a non-Smi
number points somewhere else again. That overhead is exactly what
chapter 23 will measure.
The object header is the hidden bookkeeping at the start of every object: in V8, a pointer to its hidden class, then pointers to its properties and elements stores. A field representation is how the engine has decided to store one field, recorded in the hidden class. The V8 blog describes Smi and Double representations[2]; V8’s own trace output, below, also shows heap object and tagged (anything at all).
A field that always holds doubles gets the Double representation. In the design the V8 team described, its value sits in a box that only this object can see, so V8 can update it in place without allocating. If a field starts as one representation and then receives a value that doesn’t fit, V8 widens it to a more general one[2]. The most general, Tagged, keeps doubles as ordinary immutable HeapNumbers.
Back to the two classes
In modern JavaScript, x; inside a class body is a real class field.
Fields are added before the constructor body runs, and a field without an
initializer starts as undefined[5]. You can
watch it happen:
class P {
x;
constructor(x) {
console.log('before:', this.x);
console.log('has x?', 'x' in this);
this.x = x;
}
}
const p = new P(0.5);
console.log('after:', p.x);TypeScript turns x: number; into exactly that kind of field when the
target is ES2022 or later[6]. Writing declare x: number; instead emits nothing at all, so the field appears only when
the constructor first stores a number[7]. Step
through what that does to the field:
class P {x: number; // a real fieldconstructor(x: number) {this.x = x;}}const p = new P(0.5);p.x += 0.1;
This isn’t guesswork. Running the benchmark with V8’s
--trace-generalization flag printed this line for class P:
x:h{Any;const}->t{Any;mutable}
h is the heap-object representation (the undefined) and t is
Tagged. Here is what that costs:
Table view
| layout | median ns | p95 ns |
|---|---|---|
| array of objects | 10.85 | 16.66 |
| class, TS fields | 55.51 | 105.09 |
| class, ctor fields | 8.17 | 8.82 |
Measured with the repo’s harness (engine/ts/packages/bench) on Node v22.23.4, V8 12.4.254.21-node.57, Intel(R) Xeon(R) Processor @ 2.10GHz. One fresh process per case. Shared cloud VM: expect noise. Compare ratios, not absolute times.
In this book’s benchmark run, the class with field declarations was
roughly 3 to 7 times slower than the object literal and the
constructor-only class, depending on the number of particles. The V8 blog
gives the same advice in plain JavaScript: don’t start numeric fields as
null, because it defeats representation tracking[2].
The fix is a starting value of the right kind: x = 0.0 would still be a
Smi (0 is an integer), so for doubles use declare, or assign in the
constructor only. Better still, don’t depend on it: chapter 23’s typed
columns sidestep the problem completely.
A Float64Array has none of these problems. It stores raw 64-bit doubles
side by side, with no tags, no boxes and no per-element headers[8].
The pointer compression post itself recommends Float64 typed arrays for
number-crunching code[3]. That is why
Viscosity’s struct-of-arrays columns are Float64Arrays: whatever the
source program declared, a column of doubles stays a column of doubles.
A TypeScript-aware Viscosity pass can fix the slow class for free.
Your turn
Assume V8 with pointer compression, where a Smi carries 31 bits including the sign: integers from −2^30 to 2^30 − 1. Write smiFits(n) that returns true if the number n could be stored as a Smi, and false if it would need a HeapNumber. Remember that -0 is not a Smi.
function smiFits(n) {
// your code here
}
console.log(smiFits(42), smiFits(4.2));Write exponentBits(x) that returns the 11 raw exponent bits of the double x, as a number from 0 to 2047. For example exponentBits(1) is 1023. Put x in a Float64Array and look at the same 8 bytes through a Uint32Array. Assume a little-endian machine, as in chapter 16: the high 32 bits are element [1].
function exponentBits(x) {
const f = new Float64Array([x]);
const u = new Uint32Array(f.buffer);
// your code here
}
console.log(exponentBits(1));What you learned
- Every JavaScript number behaves like an IEEE 754 double: sign, exponent, mantissa, 64 bits.
- V8 stores small integers as Smis, right inside the slot, marked by a tag bit. Other numbers become immutable HeapNumbers on the heap.
- Every object starts with an object header: a hidden-class pointer plus pointers to its properties and elements stores.
- Each field has a field representation (such as Smi, Double or Tagged), and V8 widens it when a value doesn’t fit.
- A TypeScript field declaration starts a field as
undefined, so storing doubles generalizes it to Tagged, and every update allocates.
Next: AoS against SoA, measured properly, across seven kinds of loop.
Check your understanding
0/6- 1.
In V8, how is the number
42most likely stored in a variable? - 2.
Which of these values does V8 store as a HeapNumber, not a Smi?
- 3.
let b = 4.2; b = b + 1;What happens to the HeapNumber holding4.2? - 4.
What sits at the very start of every V8 object?
- 5.
A TypeScript class declares
x: number;and its constructor stores doubles intox. Why can updates top.xallocate? - 6.
Why don’t
Float64Arraycolumns suffer from any of these representation problems?
References
- [1] MDN contributors. Number. MDN Web Docs. developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Number (checked 2026-10-31)
- [2] Benedikt Meurer, Mathias Bynens (2019). The story of a V8 performance cliff in React. V8 blog (v8.dev), 28 August 2019. v8.dev/blog/react-cliff (checked 2026-10-31)
- [3] Igor Sheludko, Santiago Aboy Solanes (2020). Pointer Compression in V8. V8 blog (v8.dev), 30 March 2020. v8.dev/blog/pointer-compression (checked 2026-10-31)
- [4] Camillo Bruni (2017). Fast properties in V8. V8 blog (v8.dev), 30 August 2017. v8.dev/blog/fast-properties (checked 2026-10-31)
- [5] MDN contributors. Public class fields. MDN Web Docs. developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Classes/Public_class_fields (checked 2026-10-31)
- [6] The TypeScript team. TSConfig reference: useDefineForClassFields. typescriptlang.org. www.typescriptlang.org/tsconfig/#useDefineForClassFields (checked 2026-10-31)
- [7] The TypeScript team (2019). TypeScript 3.7 release notes: the useDefineForClassFields flag and the declare property modifier. typescriptlang.org handbook. www.typescriptlang.org/docs/handbook/release-notes/typescript-3-7.html (checked 2026-10-31)
- [8] MDN contributors. JavaScript typed arrays. MDN Web Docs. developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Typed_arrays (checked 2026-10-31)