Viscosity
Review
Part II · How machines run code · Day 6

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.

About 16 minutes, including the exercises and quiz
By the end you will be able to
  • 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.

Intuition

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.

The 64 bits of the number 1.5: one sign bit (0), eleven exponent bits (01111111111, which is 1023), and fifty-two mantissa bits (a 1 followed by zeros).1.5 as 64 bitssign+exponent1023 → 2⁰mantissa (fraction)1.1 in binary = 1.5
A double is a sign, an exponent and a fraction. The number 1.5 in IEEE 754 form. The sign bit is 0 (positive). The exponent bits say 1023, which means 2 to the power 0. The mantissa holds the digits after an implied leading 1: binary 1.1, which is 1.5.
Definition

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

Step throughSmi or pointer? The tag bit decides
let a = 42;
let b = 4.2;
b = b + 1;
let c = 2 ** 30;
Variable slots on the left, each with a payload and a tag bit; HeapNumber boxes on the rightSLOTS (32 bits)HEAPa420tag 0 = Smi (number inside) tag 1 = pointer
42 is a small integer, so it is stored right inside the slot as a Smi. The lowest bit, 0, is the tag that says “this is a number, not an address”.
1/4
Definition

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.

Definition

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:

Try itA class field exists before the constructor
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:

Step throughOne TypeScript line, two field representations
class P {
x: number; // a real field
constructor(x: number) {
this.x = x;
}
}
const p = new P(0.5);
p.x += 0.1;
The object p as four words: hidden class, properties, elements, and the field x. The hidden class records x's representation. x may point to HeapNumber boxes on the right.OBJECT pHEAPhidden classpropertieselementsx(not yet)x’s representation:—
Every object starts with a header: a pointer to its hidden class (its shape), then pointers to its properties store and its elements store. Fields come after.
1/7

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:

Moving particles: x += vx, y += vy
Median ns per element, n = 1,000,000 (shorter is faster)
array of objects: median 10.85 ns, p95 16.66 ns per elementarray of objects10.9class, TS fields: median 55.51 ns, p95 105.09 ns per elementclass, TS fields55.5 5.1× slowerclass, ctor fields: median 8.17 ns, p95 8.82 ns per elementclass, ctor fields8.2 1.3× faster
Table view
layoutmedian nsp95 ns
array of objects10.8516.66
class, TS fields55.51105.09
class, ctor fields8.178.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.

The class with TypeScript field declarations is the slow one: every update allocates a new HeapNumber.

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

Pitfall

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.

Under the hood

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

ExerciseWould this be a Smi?

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));
Extra practiceRead a double’s exponent bits

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

    In V8, how is the number 42 most likely stored in a variable?

  2. 2.

    Which of these values does V8 store as a HeapNumber, not a Smi?

  3. 3.

    let b = 4.2; b = b + 1; What happens to the HeapNumber holding 4.2?

  4. 4.

    What sits at the very start of every V8 object?

  5. 5.

    A TypeScript class declares x: number; and its constructor stores doubles into x. Why can updates to p.x allocate?

  6. 6.

    Why don’t Float64Array columns suffer from any of these representation problems?

References

  1. [1] MDN contributors. Number. MDN Web Docs. developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Number (checked 2026-10-31)
  2. [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. [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. [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. [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. [6] The TypeScript team. TSConfig reference: useDefineForClassFields. typescriptlang.org. www.typescriptlang.org/tsconfig/#useDefineForClassFields (checked 2026-10-31)
  7. [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. [8] MDN contributors. JavaScript typed arrays. MDN Web Docs. developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Typed_arrays (checked 2026-10-31)