Viscosity
Review
Part I · JavaScript for engine builders · Day 4

16. Bits, bytes and typed arrays

Underneath every variable is a row of switches. Learn to read them, and you can see why 0.1 + 0.2 isn’t 0.3, and what the code Viscosity generates is really made of.

About 16 minutes, including the exercises and quiz
By the end you will be able to
  • Read and write small numbers in binary, and explain bits and bytes.
  • Use the bitwise operators & | ^ << >> >>> on small integers.
  • Explain what a float64 is, and two surprises it causes.
  • Create an ArrayBuffer with typed-array views over it, and store particle fields as Float64Array columns.

What is a number made of?

Last chapter, 0.1 + 0.2 came out as 0.30000000000000004. That isn’t a bug in JavaScript. It’s a consequence of how numbers are stored: as rows of tiny switches. This chapter opens the box. By the end, you’ll be able to read the kind of storage Viscosity produces: one column of raw numbers per field.

Intuition

A computer’s memory is a vast panel of light switches, each either off or on. Everything, from numbers to text to your particles, is a pattern of switches. The trick is agreeing what each pattern means.

Counting with two digits

We count in tens: the places are worth 1, 10, 100. With only the digits 0 and 1, the places are worth 1, 2, 4, 8, 16, each double the last:

Step throughCounting with switches
Eight bits with place values 128 down to 1, showing the number 01280640320160804020100 = 0binary 0 · decimal 0
Eight switches. Each one is worth double the switch to its right: 1, 2, 4, 8, up to 128. All off is 0.
1/8
console.log((13).toString(2)); // 1101
console.log(parseInt('1101', 2)); // 13
console.log(0b1101); // 13

toString(2) writes a number in base 2, parseInt(text, 2) reads it back, and the 0b prefix lets you type binary straight into your code.

Definition

Binary is writing numbers in base 2, with place values 1, 2, 4, 8, … Each binary digit is a bit: a single 0 or 1. Eight bits make a byte, which has 2 × 2 × … × 2 = 256 possible patterns, so one byte can hold a whole number from 0 to 255.

Working on bits directly

JavaScript has operators that work on the bits of a number, one position at a time:

// 13 is 1101, 6 is 0110
13 & 6    // 4   0100: on in both
13 | 6    // 15  1111: on in either
13 ^ 6    // 11  1011: on in exactly one
1 << 3    // 8   1000: slid left 3
13 >> 1   // 6   0110: slid right 1
-1 >>> 0  // 4294967295

A common use is packing yes/no flags into one number, one bit each:

const ALIVE = 1;   // 001
const VISIBLE = 2; // 010
let flags = ALIVE | VISIBLE; // 011

(flags & VISIBLE) !== 0  // true: visible
flags = flags ^ VISIBLE; // toggle it off
(flags & VISIBLE) !== 0  // false
Definition

The bitwise operators are & (and: 1 where both bits are 1), | (or: 1 where either is), ^ (xor: 1 where exactly one is), << (shift left), >> (shift right, keeping the sign) and >>> (shift right, filling with zeros). Before working, they convert numbers to 32-bit integers, dropping any fraction and any higher bits, and negative numbers use a pattern called two’s complement[2]. That is why -1 >>> 0 is 4294967295: all 32 bits on, read as unsigned.

Try itBits, shifts and float surprises
console.log((13).toString(2));
console.log(13 & 6, 13 | 6, 13 ^ 6);
console.log(1 << 3, 13 >> 1);

// Bitwise ops work on 32-bit ints:
console.log(3.7 | 0);
console.log(2 ** 32 | 0);
console.log(-1 >>> 0);

// float64 surprises:
console.log(0.1 + 0.2);
console.log(2 ** 53 + 1 === 2 ** 53);

float64: what every number really is

Those 32-bit integers are temporary. Every ordinary JavaScript number is stored as a float64: a 64-bit pattern in a format called IEEE 754 double precision[1]. It works like scientific notation (6.02 × 10²³), but in binary: one bit for the sign, 11 bits for the exponent and 52 for the digits.

The 64 bits of the number 1 stored as a float64: 1 sign bit, 11 exponent bits and 52 fraction bits.↓ sign: 1 bitfraction: 52 bits↑ exponent: 11 bitsthe number 1: 0 01111111111 000…000
Every JavaScript number is 64 bits: sign, exponent, fraction. The number 1 as a float64. Coloured cells are the bits that are on. The 11 exponent bits say “times 2 to the power 0”; the leading 1 of the number is implied, so the fraction bits are all zero.

Two surprises follow:

  • Some decimals can’t be stored exactly. In decimal, 1/3 goes on forever: 0.333… In binary, so does 0.1. The stored value is the nearest 64-bit pattern, which is why 0.1 + 0.2 is off by a hair.
  • Big integers run out of digits. Whole numbers are exact only up to 2⁵³ − 1, Number.MAX_SAFE_INTEGER[1]. Past that, 2 ** 53 + 1 === 2 ** 53 is true.
Definition

A float64 is a 64-bit (8-byte) floating-point number in the IEEE 754 double-precision format. Every JavaScript number is one[1].

Raw bytes, and views onto them

Now that a number is just 8 bytes, you can ask for the bytes directly.

Intuition

An ArrayBuffer is a strip of blank tiles. A typed array is a stencil you lay over the strip: one stencil reads the tiles 8 at a time as decimals, another 4 at a time as whole numbers. Two stencils over the same strip see the same tiles.

Step throughTwo views, one buffer
const buf = new ArrayBuffer(16);
const f64 = new Float64Array(buf);
const i32 = new Int32Array(buf);
f64[0] = 1;
i32[2] = 5;
Sixteen bytes of an ArrayBuffer, with a Float64Array view of two 8-byte slots and an Int32Array view of four 4-byte slots over the same bytesTHE 16 BYTES (as Uint8 values, byte 0 on the left)0000000000000000f64 (8 bytes each)i32 (4 bytes each)
new ArrayBuffer(16) is sixteen bytes, all zero. A buffer is raw storage with no meaning attached: you can’t read numbers out of it directly.
1/5

The diagram shows a little-endian layout, with the low byte first. Typed arrays use whatever byte order the machine natively has[4].

const buf = new ArrayBuffer(16);
const f64 = new Float64Array(buf);
const i32 = new Int32Array(buf);
console.log(f64.length, i32.length); // 2 4

const xs = new Float64Array(3);
console.log(xs); // [ 0, 0, 0 ]

You can also skip the buffer: new Float64Array(3) makes its own, filled with zeros. Typed arrays behave like arrays for reading and writing, xs[i], but with three differences. Their length is fixed, so there is no push. Every element has the same type. And values are converted to fit: an Int32Array stores 3.7 as 3, and a Uint8Array stores 256 as 0[4].

Definition

An ArrayBuffer is a fixed-size block of raw bytes, initially all zero. You can’t read it directly; you read it through a view[3]. A typed array (Float64Array, Int32Array, Uint8Array, …) is a view that reads a buffer as numbers of one type and size[5]. A Float64Array holds float64s, 8 bytes each, packed side by side.

Columns of numbers

Here’s the payoff. Chapter 3 showed the struct-of-arrays idea: one array per field instead of one object per particle. With typed arrays, each column is one solid block of float64s:

const n = 3;
const x = new Float64Array(n);
const vx = new Float64Array([2, 0, -1]);

for (let i = 0; i < n; i++) {
  x[i] = x[i] + vx[i] * 0.5;
}
console.log(x); // [ 1, 0, -0.5 ]

No objects and no arrows to follow: x is 3 × 8 = 24 bytes of numbers in a row.

Under the hood

This is exactly the storage Viscosity generates: one Float64Array per field. The benchmark in this repository compares it with plain objects (engine/ts/packages/bench/results, Node 22 on a shared cloud machine, so treat the numbers as rough). Summing one field over a million particles took about 1.1 ns per particle with Float64Array columns, against about 8.9 ns with an array of objects. But when the program needed a real object for every one of those million particles, the columns had to build them all, and came out over 30 times slower than objects that already existed. Neither layout always wins. That is why Viscosity has to analyse how your program uses its data before choosing.

Your turn

ExerciseFrom objects to columns

Write toSoa(ps). ps is an array of objects like { x: 1, y: 2 }. Return an object { x, y } where x is a Float64Array holding every particle’s x in order, and y a Float64Array holding every y. This is the layout change Viscosity makes.

function toSoa(ps) {
  // your code here
}

const ps = [
  { x: 1, y: 2 },
  { x: 3, y: 4 },
];
console.log(toSoa(ps));
Extra practiceCount the switches that are on

Write countBits(n): how many bits are 1 in the 32-bit pattern of n? For example, 13 is 1101, so countBits(13) is 3. Use & and >>>. (For -1, all 32 bits are on.)

function countBits(n) {
  // your code here
}

console.log(countBits(13));

What you learned

  • Binary writes numbers with place values 1, 2, 4, 8, … Each digit is a bit; eight bits are a byte, holding 0 to 255.
  • The bitwise operators & | ^ << >> >>> work on 32-bit integer patterns, bit by bit.
  • Every JavaScript number is a float64: 64 bits, IEEE 754. So 0.1 isn’t exact, and integers are exact only up to 2⁵³ − 1.
  • An ArrayBuffer is raw bytes. A typed array is a fixed-length, single-type view of them. Two views of one buffer alias the same bytes.
  • A Float64Array per field is the struct-of-arrays layout Viscosity generates.

That’s the end of Part I. You now have every JavaScript tool the engines in this book are built from.

Check your understanding

0/6
  1. 1.

    What is the binary number 1011 in decimal?

  2. 2.

    What is 12 & 10? (12 is 1100, 10 is 1010.)

  3. 3.

    How many bytes does one element of a Float64Array take?

  4. 4.

    const b = new ArrayBuffer(8); const f = new Float64Array(b); const u = new Uint8Array(b); What are f.length and u.length?

  5. 5.

    You write const a = new Int32Array(1); a[0] = 2.9;. What is a[0]?

  6. 6.

    Why does Viscosity use one Float64Array per field, instead of an array of objects?

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] MDN contributors. Bitwise AND (&). MDN Web Docs. developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Bitwise_AND (checked 2026-10-31)
  3. [3] MDN contributors. ArrayBuffer. MDN Web Docs. developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/ArrayBuffer (checked 2026-10-31)
  4. [4] MDN contributors. TypedArray. MDN Web Docs. developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/TypedArray (checked 2026-10-31)
  5. [5] MDN contributors. JavaScript typed arrays. MDN Web Docs. developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Typed_arrays (checked 2026-10-31)