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.
- 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.
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:
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.
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.
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.
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:
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);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.
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;
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/rustis complete: it emits byte-identical code and reports to the TypeScript engine’s. Itswasmcrate wraps the whole compiler and exposescompile(source), returning{ code, explain }. Built for thewasm32-unknown-unknowntarget, 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.
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
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 };
}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.instantiateand calls their exports.i32arithmetic 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.
What do the first four bytes
00 61 73 6dof a module do? - 2.
A function body is
20 00 20 01 6a 0b. What kind of machine runs it? - 3.
Why does the book’s runnable example use
new WebAssembly.Moduleinstead ofWebAssembly.instantiate? - 4.
A module’s memory is 1 page. What happens on
f64.loadat byte 65,536? - 5.
After
mem.grow(1), an oldFloat64Arrayovermem.bufferhas length 0. What should a SoA back end do? - 6.
Which statement about Viscosity and WebAssembly is true at the time of writing?
References
- [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] 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] 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] 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)