Read

What WebAssembly actually is, for people who are not compiler engineers

Not a language and not a virtual machine in the Java sense: a binary instruction format for a stack machine, one growable block of memory, and four numeric types — and why that combination is what makes a PC emulator possible in a tab.

There is no WebAssembly virtual machine. That is worth getting straight first, because the name invites a comparison with Java and the comparison is wrong. The JVM is a runtime you install: a garbage collector, a class loader, a standard library, and firm opinions about how objects behave. WebAssembly is a file format — a compact binary encoding of instructions for an abstract stack machine — and every browser already contains an engine that turns it into real machine code. It brings nothing with it. No library, no strings, no objects, and no idea what a class is.

A binary format for a stack machine

The abstract machine has no registers. Instructions push and pop values on a stack: i32.const 2 pushes the number two, i32.add pops two values and pushes their sum. Functions have typed parameters, typed results and typed local variables, and that is very nearly the whole model. The distributed form is a compact binary, a .wasm file, but there is an exactly equivalent text form called WAT that tools and browsers will print for you. Here is a complete module in it.

(module
  ;; one linear memory, 1 page = 64 KiB, visible to JavaScript
  (memory (export "mem") 1)

  ;; sum $len 32-bit integers starting at byte offset $ptr
  (func (export "sum") (param $ptr i32) (param $len i32) (result i32)
    (local $i i32)
    (local $acc i32)
    (block $done
      (loop $next
        (br_if $done (i32.ge_u (local.get $i) (local.get $len)))
        (local.set $acc
          (i32.add (local.get $acc)
                   (i32.load (i32.add (local.get $ptr)
                                      (i32.mul (local.get $i) (i32.const 4))))))
        (local.set $i (i32.add (local.get $i) (i32.const 1)))
        (br $next)))
    (local.get $acc)))
  • (module …) is the outermost unit: a passive list of types, functions, memories, tables and imports. It does nothing until something instantiates it.
  • (memory (export "mem") 1) declares one linear memory, initially one page. A page is 64 KiB, always. Exporting it means JavaScript can see the same bytes.
  • (param $ptr i32) is a pointer only by convention. It is an ordinary integer that the code chooses to treat as an offset into memory. The format has no pointer type.
  • (local $i i32) — locals are declared up front and typed. No allocation happens here, and there is no heap to allocate from.
  • (block $done … (loop $next …)) — control flow is structured. You get blocks, loops and if, and you branch to a label that encloses you: br_if $done leaves the block, br $next returns to the top of the loop. No instruction jumps to an arbitrary address — the design decision that most shapes what WebAssembly is good at.
  • i32.load is the only way to touch memory, along with i32.store. It reads four bytes at the computed address; past the end of the memory it traps rather than reading anything.
  • (local.get $acc) at the end leaves a value on the stack, and that is the function's result. The validator checks it against the declared (result i32) before the module may run.

Module, instance, memory

Three nouns do most of the work. A module is compiled code: immutable, stateless, and cheap to share — you can pass one to a Web Worker without recompiling it. An instance is a module plus everything it asked to have imported, with its own memory, its own globals and its own tables. Two instances of the same module share no state whatsoever. A memory is a WebAssembly.Memory object, which on the JavaScript side is a thin wrapper around an ArrayBuffer. new WebAssembly.Instance(module, imports) is where the three meet, and after that call the module's exported functions are ordinary callable JavaScript values.

Linear memory is both the speed and the cage

A module's memory is one contiguous buffer of bytes, addressed from zero. There is no separate stack region, no separate heap, no memory map. memory.grow(n) adds n pages of 64 KiB and returns the old size; in the 32-bit version of the format an address is an i32, so the ceiling is 4 GiB. Every load and store is bounds-checked against a single length, and on 64-bit hosts engines usually reserve a large slab of virtual address space so the hardware does the checking for free. The practical result is that a memory access in WebAssembly costs about what it costs in C.

The same arrangement is the sandbox. A pointer inside a module is an offset into that buffer, not an address in the browser's process. A wild pointer, a smashed stack, a buffer overrun with malicious intent — all of it lands somewhere inside the module's own memory. It can corrupt the program's own data, and that is the end of its reach. It cannot read the page, the other tabs, or anything the browser has not explicitly handed over. That is the property that makes it reasonable to put an entire operating system's RAM inside a tab.

Four types, and everything else is an address

i32, i64, f32, f64. Later versions of the specification add a 128-bit vector type for SIMD and some opaque reference types, but the numeric list is those four. There is no string type, no struct, no array, no boolean and no pointer. A struct is bytes at an offset you agreed on with the compiler. A string is a length and an offset, and its encoding is a convention between the two sides. This is why handing a string from WebAssembly to JavaScript means reading bytes out of the memory and decoding them — there is genuinely nothing else to hand over.

Why it compiles fast

Before a module runs, the engine validates it: every instruction's operand types are checked against the stack, every branch target is checked to be an enclosing label, every function's declared result is checked against what it actually leaves behind. This can be done in a single pass, because nothing is dynamic. Structured control flow means the engine never has to reconstruct a control-flow graph from a set of arbitrary jump targets — the graph is spelled out by the nesting. Engines then compile in tiers: a baseline compiler produces mediocre code almost immediately, and an optimising compiler quietly replaces it. WebAssembly.instantiateStreaming starts all of this while the bytes are still arriving over the network.

The four ways JavaScript and WebAssembly talk

  • Imports. A module declares the functions and globals it needs from outside, and JavaScript supplies them at instantiation. This is how a module reaches anything at all — the DOM, the network, the clock. None of it is built in.
  • Exports. Functions, memories, tables and globals the module offers. Calling an exported function from JavaScript is an ordinary call; the arguments and the result are numbers.
  • Memory. The shared ArrayBuffer. Both sides read and write the same bytes with no copying and no serialisation, which is the only cheap way to move anything larger than a number.
  • Tables. An array of function references, indexable at runtime. WebAssembly calls through one with call_indirect; JavaScript can write entries into it. This is how function pointers work, and it is also how a running program can acquire code it did not have when it started.

It began because asm.js worked

In 2013 Mozilla proposed asm.js: a strict subset of JavaScript in which x|0 forces a value to a 32-bit integer, +x forces it to a double, and all memory is a single typed array. Code in that subset is valid JavaScript everywhere, so it runs in any browser — but an engine that recognises the pattern can skip the guessing and compile it ahead of time. Emscripten produced it from C and C++, and games and emulators shipped that way. The costs were real: megabytes of JavaScript source to parse, engine-specific validation rules, and a failure mode where a small change quietly dropped you out of the fast path. WebAssembly is asm.js with the pretence dropped — the same machine model, given an explicit binary encoding and a written specification. It reached cross-browser consensus in March 2017 and became a W3C Recommendation in December 2019.

What it is not good at

  • No DOM, and no anything else. A module cannot read an element, open a socket or ask the time without an imported JavaScript function doing it on its behalf. Rewriting UI code in WebAssembly often makes it slower, because the work was never arithmetic.
  • Crossing the boundary costs something. It is far cheaper than it was, but a design that calls into JavaScript once per pixel will lose to one that fills a buffer and calls once.
  • Threads need cross-origin isolation. Shared memory means SharedArrayBuffer, and since 2018 that requires the page to be served with Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp. Without those two headers there is no shared memory and therefore no threads, whatever your module declares.
  • Big modules still take time to start. Compiling several megabytes is fast, not instant.
  • It is not automatically faster than JavaScript. For code a modern JIT specialises well, the two can be a wash. What WebAssembly reliably gives you is not a higher ceiling but a much higher floor.

Why an x86 emulator needs it

JavaScript could already emulate a PC. Fabrice Bellard's JSLinux booted Linux in a browser in 2011, and v86 itself began as JavaScript. What JavaScript could not do was be dependable about it. Its engines infer types and can throw away an optimisation when a guess turns out wrong; integers are floating-point doubles until proved otherwise; a hot function can be dropped back to the interpreter for reasons invisible from the source. An emulator's inner loop is 32-bit integer arithmetic that has to wrap exactly the way the original silicon wrapped, several hundred million times a second. That is precisely what i32 is, exactly, every time, with no speculation to undo.

The second reason is less obvious, and it is the one that matters most here. WebAssembly can be generated at runtime. A just-in-time compiler needs somewhere to put the code it produces, and WebAssembly.instantiate will accept a byte array that the program built for itself a moment earlier. So an emulator can translate a hot page of guest x86 into a small .wasm module, hand it to the browser, and receive back a function compiled by the same optimising backend that compiled the emulator. Drop that function into a table and it is callable by index, alongside every other block already compiled. The third reason is linear memory: if the guest's entire RAM is one WebAssembly memory, then a guest memory read is one bounds-checked load rather than a method call on a JavaScript object.

How x86 emulation works in the browser follows that through in detail — which pages get compiled, when they are thrown away, and where the guest's registers sit in the buffer. What is a temporary virtual machine covers why none of it survives a reload, and the glossary has the short definitions. Or you can mount a machine and watch the second compiler tier arrive about ten seconds into a boot.