Viscosity
Review
Part VIII · Beyond JavaScript · Day 30

117. WebAssembly as a target

Every browser ships a second virtual machine next to its JavaScript engine. You can hand it bytecode you wrote yourself, byte by byte, and its one flat memory is exactly where struct-of-arrays columns want to live.

About 18 minutes, including the exercises and quiz
By the end you will be able to
  • Read a small WebAssembly module byte by byte and say what each section does.
  • Load a module from JavaScript and call its exported function.
  • Explain linear memory, pages, traps, and how JavaScript and WebAssembly share it.
  • Lay out SoA columns in linear memory, and say what Viscosity has and hasn’t built here.

A VM you didn’t have to write

In Part IV you wrote a bytecode VM for Drip. Your compiler and your VM agreed on a private format: which byte means OP_ADD, how a constant is stored. Nobody else could run your bytecode.

Now imagine a bytecode format that every browser and Node already understand, specified so precisely that independent engines run it the same way. That is WebAssembly, the last back end this track looks at.

Intuition

A shipping container. The shipper doesn’t care which port, crane or truck handles the box, because every one of them is built for the same standard size. WebAssembly is a standard box for code: compile your program into it once, and any engine with the right “cranes” can load it and run it fast.

Forty-one bytes

Here is a whole WebAssembly program, written by hand. It exports one function, add, that adds two 32-bit integers:

const bytes = new Uint8Array([
  0x00, 0x61, 0x73, 0x6d, // magic
  0x01, 0x00, 0x00, 0x00, // version 1
  0x01, 0x07, 0x01, 0x60, // type
  0x02, 0x7f, 0x7f, 0x01, 0x7f,
  0x03, 0x02, 0x01, 0x00, // function
  0x07, 0x07, 0x01, 0x03, // export
  0x61, 0x64, 0x64, 0x00, 0x00,
  0x0a, 0x09, 0x01, 0x07, // code
  0x00, 0x20, 0x00, 0x20, 0x01,
  0x6a, 0x0b,
]);

Every byte has a job. Step through them:

Step throughReading a WebAssembly module
The bytes of a WebAssembly module that exports add, grouped into the preamble and four sections, with one group highlighted at a timemagic0061736dversion01000000type01070160027f7f017ffunction03020100export070701036164640000code0a09010700200020016a0bWHAT IT MEANS41 bytes: a preamble, then four sections
The whole module, every byte of it. The first 8 bytes are a preamble. After that come sections, each starting with a one-byte id and a length. Step through to read them.
1/10

The format begins with the 4-byte magic number \0asm and version 1, then a list of sections. Each section is a one-byte id, the length of its contents, and the contents. The ids used here are 1 (types), 3 (functions), 7 (exports) and 10 (code), and those sections must appear in that order[1]. Lengths and other integers are written in LEB128, a variable-length encoding where small numbers take a single byte, which is why each length here is one byte.

The body, 20 00 20 01 6a 0b, is bytecode for a stack machine, like Drip’s. The difference is what happens before it runs. An engine first validates the module, type-checking every function so that, for example, i32.add can never find a string or an empty stack. The specification notes that engines don’t need a real operand stack at all: because validation fixes the stack height at every point, the slots can be treated as registers[1], and engines compile the module to machine code.

Definition

WebAssembly (Wasm) is a standardized, portable, low-level binary format for code. A module is a preamble (\0asm, version) followed by sections: types, functions, memories, exports, code and others. Function bodies are bytecode for a typed stack machine. Engines validate a module before running it, and usually compile it to machine code.

Calling it from JavaScript

JavaScript loads a module through the WebAssembly object, usually asynchronously, so compiling doesn’t freeze the page[2]:

const { instance } =
  await WebAssembly.instantiate(bytes);
console.log(instance.exports.add(40, 2));
// 42

The runnable example uses the synchronous constructors instead, because the book’s runner stops as soon as your code returns. Look at the last line: i32 arithmetic wraps around at 2³², instead of turning into a bigger number the way JavaScript’s numbers do.

Try itA module written by hand
const bytes = new Uint8Array([
  0x00, 0x61, 0x73, 0x6d, // magic
  0x01, 0x00, 0x00, 0x00, // version
  // type: (i32, i32) -> i32
  0x01, 0x07, 0x01, 0x60,
  0x02, 0x7f, 0x7f, 0x01, 0x7f,
  // function: f0 has type 0
  0x03, 0x02, 0x01, 0x00,
  // export: "add" is f0
  0x07, 0x07, 0x01, 0x03,
  0x61, 0x64, 0x64, 0x00, 0x00,
  // code: the body of f0
  0x0a, 0x09, 0x01, 0x07, 0x00,
  0x20, 0x00, // local.get 0
  0x20, 0x01, // local.get 1
  0x6a,       // i32.add
  0x0b,       // end
]);
const mod =
  new WebAssembly.Module(bytes);
const inst =
  new WebAssembly.Instance(mod);
const add = inst.exports.add;
console.log(bytes.length);
console.log(add(2, 3));
console.log(add(2147483647, 1));

One flat memory

The add function only touched its parameters. Real programs need memory, and here WebAssembly is very different from JavaScript. There are no objects, no shapes, no garbage-collected heap in the classic design. There is one long array of bytes, the linear memory, and instructions such as f64.load and f64.store read and write 8 bytes at a numeric address[1].

That is chapter 17’s picture, “memory is one long array”, made literal. The memory grows in pages of 65,536 bytes. Numbers are stored little-endian, smallest byte first. And an access past the end doesn’t corrupt anything: it traps, which JavaScript sees as a thrown WebAssembly.RuntimeError.

Intuition

A JavaScript heap is a city: objects live at addresses you never see, and the engine’s garbage collector moves them around. Linear memory is one very long street of numbered lockers. You choose which locker holds what, and if you ask for locker 70,000 on a street of 65,536, a guard stops you.

JavaScript sees the same memory as an ArrayBuffer, memory.buffer. Put a Float64Array over it and both sides look at the same bytes. In the next example, the module exports its memory and a get(i) function that runs f64.load at address i * 8. JavaScript writes a column of numbers, and WebAssembly reads them:

Try itA column shared with WebAssembly
const bytes = new Uint8Array([
  0x00, 0x61, 0x73, 0x6d,
  0x01, 0x00, 0x00, 0x00,
  // type: (i32) -> f64
  0x01, 0x06, 0x01,
  0x60, 0x01, 0x7f, 0x01, 0x7c,
  // function: f0 has type 0
  0x03, 0x02, 0x01, 0x00,
  // memory: 1 page to start
  0x05, 0x03, 0x01, 0x00, 0x01,
  // export: "mem" and "get"
  0x07, 0x0d, 0x02,
  0x03, 0x6d, 0x65, 0x6d, 0x02, 0x00,
  0x03, 0x67, 0x65, 0x74, 0x00, 0x00,
  // code: f64.load at i * 8
  0x0a, 0x0c, 0x01, 0x0a, 0x00,
  0x20, 0x00, // local.get 0
  0x41, 0x08, // i32.const 8
  0x6c,       // i32.mul
  0x2b, 0x03, 0x00, // f64.load
  0x0b,       // end
]);
const m = new WebAssembly.Module(bytes);
const { mem, get } =
  new WebAssembly.Instance(m).exports;
console.log(mem.buffer.byteLength);

const xs = new Float64Array(
  mem.buffer, 0, 4);
xs.set([1.5, 2.5, 3.5, 4.5]);
console.log(get(2));
xs[2] = 99;
console.log(get(2));

// 1.5 is 3ff8000000000000 in hex:
// smallest byte first
const b = new Uint8Array(mem.buffer);
console.log(...b.slice(0, 8));

try {
  get(8192); // byte 65536: too far
} catch (e) {
  console.log(e.name, e.message);
}
mem.grow(1);
console.log(xs.length);
Definition

Linear memory is a WebAssembly module’s contiguous, mutable array of raw bytes, addressed by integers from 0. Its size is a whole number of 64 KiB pages and it can grow. Loads and stores are little-endian, and an out-of-bounds access traps. JavaScript reaches it as memory.buffer, an ArrayBuffer that typed arrays can view.

Pitfall

Growing the memory detaches the old ArrayBuffer and makes a new one[2]. A Float64Array view made before the grow suddenly has length 0. Make views again after every grow, the same way chapter 105’s grow() replaced its columns.

Columns in linear memory

Viscosity’s SoA containers already hold their numeric fields in Float64Array columns. In linear memory, those columns become regions of one memory. With capacity cap, field number k can start at byte k * cap * 8, so element i of that field is at:

const addr = k * cap * 8 + i * 8;
Linear memory as one row of bytes. The x column fills bytes 0 to 31 and the vx column bytes 32 to 63. Element 2 has x at byte 16 and vx at byte 48. WebAssembly reads them with f64.load at those addresses; JavaScript sees each column through a Float64Array view of the same bytes.f64.load 16f64.load 48x[0]8 bytes0x[1]8 bytes8x[2]8 bytes16x[3]8 bytes24vx[0]8 bytes32vx[1]8 bytes40vx[2]8 bytes48vx[3]8 bytes56new Float64Array(buf, 0, 4)new Float64Array(buf, 32, 4)byte addresses under each slot · capacity 4column k starts at byte k × capacity × 8
SoA in linear memory is a list of start addresses, one per column. A design for two columns, x and vx, with capacity 4. Each f64 is 8 bytes, so the vx column starts at byte 32. WebAssembly loads from the addresses; JavaScript views each column as a Float64Array over the same memory.

A loop over x and vx walks two contiguous runs of bytes, the pattern Part II measured as fast. The WebAssembly SIMD instructions you met in chapter 21, such as f64x2.add, can then handle two lanes per instruction[4].

So is this what Viscosity does? Not yet, and the honest answer has three parts:

  • The TypeScript engine has no WebAssembly back end. Its plugins rewrite source code: JavaScript in, JavaScript out, plus Python.
  • WebAssembly runs the compiler, not its output. The Rust port in engine/rust is complete: it emits byte-identical code and reports to the TypeScript engine’s. Its wasm crate wraps the whole compiler and exposes compile(source), returning { code, explain }. Built for the wasm32-unknown-unknown target, it reproduces every parity fixture too, so Viscosity itself can run in a browser as WebAssembly (Track B, chapter 189). What it produces is still JavaScript. The book’s playground uses the TypeScript engine, bundled as JavaScript.
  • Emitting loops as WebAssembly is a design, not code. Strings and objects have no place in linear memory, columns would have to move when the memory grows, and every call between JavaScript and WebAssembly crosses a boundary. Whether a hot loop would gain enough to pay for that is a question nobody has measured here.
Under the hood

WebAssembly keeps growing through proposals. Fixed-width SIMD became part of version 2.0. Version 3.0 added, among others, garbage collection, multiple memories and 64-bit memories (Memory64)[3]. With garbage collection, a compiler can ask the engine for managed structs and arrays instead of managing linear memory by hand, so a future Viscosity back end would face the same choice this whole track has been about: which layout, decided by whom.

Your turn

ExercisePlan the columns in linear memory

Write planMemory(fs, cap). fs is a list of field names. Each field becomes a column of cap 8-byte numbers, placed one after another in linear memory, in the order given. Return { offsets, bytes, pages }: offsets maps each field to the byte where its column starts, bytes is the total size of all columns, and pages is how many 64 KiB pages (65,536 bytes each) are needed to hold them.

function planMemory(fs, cap) {
  const offsets = {};
  for (let k = 0; k < fs.length; k++) {
    offsets[fs[k]] = k * cap;
  }
  const bytes = fs.length * cap;
  return { offsets, bytes, pages: 0 };
}
Extra practiceEncode a length in LEB128

Section lengths and other integers in a module are written in unsigned LEB128. Write uleb(n), which returns the bytes for a non-negative integer n as an array: take the lowest 7 bits; shift n right by 7; if anything is left, set the top bit (0x80) of this byte and continue; otherwise this is the last byte. So uleb(127) is [127] and uleb(128) is [0x80, 0x01].

function uleb(n) {
  return [n];
}

What you learned

  • WebAssembly is a standardized, portable binary format for code: a preamble (\0asm, version 1) and sections with ids, lengths and contents. Bodies are bytecode for a stack machine that engines validate and then compile.
  • JavaScript loads modules with WebAssembly.instantiate and calls their exports. i32 arithmetic wraps around.
  • Linear memory is one flat, growable array of bytes, in 64 KiB pages, little-endian, and trapping on out-of-bounds access. JavaScript views it through memory.buffer, which grow detaches.
  • SoA columns map onto linear memory as one start address per column. Viscosity doesn’t emit WebAssembly. The Rust port compiles the compiler to WebAssembly, which is a different job.

Check your understanding

0/6
  1. 1.

    What do the first four bytes 00 61 73 6d of a module do?

  2. 2.

    A function body is 20 00 20 01 6a 0b. What kind of machine runs it?

  3. 3.

    Why does the book’s runnable example use new WebAssembly.Module instead of WebAssembly.instantiate?

  4. 4.

    A module’s memory is 1 page. What happens on f64.load at byte 65,536?

  5. 5.

    After mem.grow(1), an old Float64Array over mem.buffer has length 0. What should a SoA back end do?

  6. 6.

    Which statement about Viscosity and WebAssembly is true at the time of writing?

References

  1. [1] W3C WebAssembly Community Group (editor: Andreas Rossberg). WebAssembly Core Specification. WebAssembly/spec repository on GitHub (rendered at webassembly.github.io/spec/core). webassembly.github.io/spec/core/ (checked 2026-10-31)
  2. [2] W3C WebAssembly Community Group (editor: Ms2ger). WebAssembly JavaScript Interface. WebAssembly/spec repository on GitHub (rendered at webassembly.github.io/spec/js-api). webassembly.github.io/spec/js-api/ (checked 2026-10-31)
  3. [3] The WebAssembly Community Group. Finished Proposals. WebAssembly/proposals repository on GitHub. github.com/WebAssembly/proposals/blob/main/finished-proposals.md (checked 2026-10-31)
  4. [4] The WebAssembly Community Group (SIMD subgroup). WebAssembly 128-bit packed SIMD Extension (proposal overview, SIMD.md). WebAssembly/simd repository on GitHub. github.com/WebAssembly/simd/blob/main/proposals/simd/SIMD.md (checked 2026-10-31)