Infraspec

The WebAssembly Execution Model

A stack machine with structured control flow, one linear memory, no ambient authority and a validation pass that succeeds or fails in one sweep. Every one of those choices exists so a host can prove things about code it did not write.

The question

What does the WebAssembly machine actually look like, and why is it shaped like that?

SourceLexingTokensParsingASTSemanticsTypedIROptimizeCodegenMachine codeLinkExecute
What the program is here

An abstract machine with four pieces of state and one unusual restriction. Typed locals and an operand stack per function; a single linear memory, a contiguous byte array with a declared minimum size that can grow but never shrink; a table of references, used for indirect calls; and a set of imported values supplied by the host. The restriction is that control flow is structured — blocks, loops and if constructs with branches that may only target an enclosing construct — so there are no arbitrary jumps anywhere in the format. The question the machine exists to answer is how to express arbitrary computation in a form a host can validate in one linear pass.

What this phase may assume or do

Validation is the precondition for everything, and it is total: every instruction is checked against the types on the operand stack at that program point, every branch must target a structurally enclosing label, every function call must match a declared type, and every memory instruction must reference the module's own memory. A module that passes may then be assumed by the runtime to be type-correct and control-flow-safe, which is what licenses compiling it to native code without emitting checks on the control flow. What may never be assumed is anything about the module's intent: it may loop forever, it may trap, and it may read any byte of its own linear memory including ones its source language considered uninitialised — none of which validation prevents, and all of which the sandbox must survive.

Key points

  • The instruction set is a stack machine, chosen for compact encoding; what actually executes after compilation is register machine code.
  • Locals are typed and not addressable, which is why toolchains synthesise a shadow stack in linear memory for address-taken variables.
  • Control flow is structured with no arbitrary jumps, so validation is a single forward pass carrying one stack type.
  • That restriction pushes work onto the compiler, which must reconstruct structure from a CFG and pay code duplication or a dispatch loop for irreducible flow.
  • Memory is one contiguous, bounds-checked, grow-only linear region, and no instruction can name an address outside it.
  • A module starts with no capabilities at all: everything it can do arrives as an import the host chose to supply.
  • The guarantees are about the boundary. Memory bugs inside a module, non-termination and side channels are all still possible.

A stack machine, and why the format is not the execution

The instruction set is a stack machine's: operands are pushed, an operator pops them and pushes a result. i32.add pops two 32-bit integers and pushes their sum; there are no registers to name and no register allocation to encode. That is the same design as most bytecode formats and for the same reason — see [[stack-based-vm]] — and the reason is compactness. A stack instruction does not need operand fields, so the encoding is small, and small matters when the artifact is downloaded before anything runs.

The critical thing to keep separate is that the stack machine is the *format*, not the execution model of the code that finally runs. A runtime that compiles the module produces ordinary register machine code, and the operand stack disappears entirely into virtual registers and then physical ones. It is the same relationship as [[bytecode]] to a JIT-compiled function: the linear format is a serialisation, and treating it as a description of what the CPU does leads to wrong conclusions about performance.

Locals are typed and are *not* addressable. This matters more than it sounds: a C or C++ program that takes the address of a local cannot use a Wasm local for it, so toolchains synthesise a shadow stack inside linear memory for exactly those variables. It is why WebAssembly can validate so cheaply — a local's type is known statically and can never be aliased — and it is a real cost paid by languages whose model assumes every variable has an address.

The same function, binary and text
1(func $add (param $a i32) (param $b i32) (result i32)
2 local.get $a
3 local.get $b
4 i32.add)
5
6;; validation, one pass, no backtracking:
7;; local.get $a -> stack: [i32]
8;; local.get $b -> stack: [i32, i32]
9;; i32.add -> pops [i32, i32], pushes [i32]
10;; end -> stack is [i32], matches declared result. Valid.

The validator carries one thing: the current stack type. Every instruction has a declared effect on it, so checking is a single forward sweep with no fixed-point iteration and no dataflow analysis. That is a deliberate design constraint, not a happy accident — a format with arbitrary jumps could not be checked this way.

Structured control flow, which is why one pass is enough

WebAssembly has no goto. Control flow is expressed with block, loop and if constructs, and branches — br, br_if, br_table — may only target a label belonging to an enclosing construct. A br out of a block jumps to its end; a br targeting a loop jumps to its start. There is no way to write a jump into the middle of another construct, because no such target can be named.

That restriction is what makes single-pass validation possible. With arbitrary jumps, the type of the operand stack at an instruction depends on every path that could reach it, and checking becomes a dataflow problem requiring iteration to a fixed point — precisely the machinery [[data-flow-framework]] describes. With structured control flow, the stack type at every point is determined by the enclosing constructs, so the validator carries one stack type and sweeps forward. Validation cost is linear in module size and there is no worst case that blows up.

The price falls on the compiler. A backend has an arbitrary control-flow graph and must reconstruct structure from it — the relooper or stackifier problem — and irreducible control flow, which a CFG can express and structured constructs cannot, requires either code duplication or a dispatch loop with a state variable. Both cost real code size and speed. This is the clearest case in the domain of a target constraint pushing work back up the pipeline, and it is why the multi-value and later control-flow proposals exist. See [[control-flow-graph]] and [[natural-loops]] for what the compiler is working from.

Irreducible control flow: expressible as a graph, not as structured constructs
  1. entryentryentry
    br_if cond -> B
    br -> A
  2. AA
    ...
    br -> B
    Reached from entry, and from B.
  3. BB
    ...
    br -> A
    Also reached from entry — so the loop has two entry points.
  4. exitexit
    return
Edges
  • entryA
  • entryB
  • AB
  • BA
  • Bexit

Read it asThe cycle between A and B has two entry points from outside it, so no single construct encloses it — there is no loop whose body is both A and B with the right entry. A Wasm backend must either duplicate one of the blocks or introduce a state variable and a dispatch loop containing a br_table. Both are correct and both cost size and speed, which is the concrete price of the restriction that makes validation one pass.

Linear memory, and what the sandbox actually is

A module's memory is one contiguous byte array with a declared minimum size, addressed from zero, which can be grown with memory.grow and never shrunk. Every load and store names an offset into it, and every access is bounds-checked against the current size — in practice usually by the runtime reserving a large guard region so that the hardware's own memory protection performs the check, but the semantic guarantee is the same either way.

The consequence is the safety property that matters: a module cannot address anything outside its own memory. There is no pointer to host memory, no way to construct one, and no instruction that takes an absolute machine address. A buffer overflow inside a module corrupts that module's own data — which can absolutely be a serious bug in the module's own logic — and cannot reach the host or another instance. This is memory safety at the *boundary*, not inside the module, and conflating the two is the most common overstatement made about WebAssembly.

The second half of the sandbox is the absence of ambient authority. A native process starts with the ability to make any system call the operating system permits; a Wasm instance starts with nothing. Files, clocks, randomness, network access and console output all arrive as imports the host explicitly provided, so a host that grants nothing has given a module that can compute and cannot observe or affect anything. That is capability-based security by construction — the design cannot express ambient authority, so there is nothing to configure wrongly — and it is what makes running untrusted modules a reasonable thing to do at all.

What the design guarantees, and what it does notspec
PropertyGuaranteed?Why, or why not
Module cannot read host memoryYesNo instruction can name an address outside the module's own linear memory
Module cannot call a syscall directlyYesThere is no syscall instruction; every capability is an import the host supplied
Type errors cannot occur at run timeYesValidation type-checks every instruction before execution begins
Control flow cannot jump to arbitrary codeYesBranches target structurally enclosing labels; indirect calls are type-checked against the table
Memory bugs inside the module are preventedNoA C program compiled to Wasm can still overflow its own buffers and corrupt its own state
Termination is guaranteedNoA module may loop forever; the host must impose fuel, an interrupt or a timeout
Side channels are preventedNoTiming and resource-exhaustion channels are outside what the format can address

Where it can still surprise you

implementationProposal support differs sharply between runtimes and versions: browsers, Wasmtime, Wasmer, WAMR and Wasm3 have adopted threads, SIMD, exception handling and WasmGC on quite different schedules, and some embedded runtimes implement only the 1.0 core. A module built with a proposal the host does not support fails validation outright, so this is a hard compatibility boundary rather than a performance difference.

Memory only grows. An instance whose peak usage is high early on holds that memory for its lifetime, because memory.grow has no counterpart. For long-lived instances or many concurrent ones, that changes capacity planning in a way that native processes and their allocators do not.

The host boundary is a real cost. A call from host to module or module to host is not a function call in the same address space with the same conventions — arguments must be marshalled, and anything larger than a scalar must be copied into or out of linear memory, since the host cannot hand the module a pointer to its own data. A workload structured as many small crossings can spend most of its time on the boundary, and restructuring it into fewer, larger calls is usually the single largest available improvement.

And the feature set is a moving target. The 1.0 core is stable and universally implemented, but threads, SIMD, exceptions, tail calls, reference types, garbage collection and the component model have each arrived as proposals implemented at different times by different runtimes. "Does this run everywhere" is really "does every runtime I target implement every proposal my toolchain used", and the failure mode is a validation error rather than a graceful degradation.

How it works

The steps, in the order the compiler takes them.

  • A module declares its types, imports, functions, table, memory and exports in separate binary sections.
  • The host validates it in one forward pass, tracking the operand stack type through each instruction and checking every branch target, call type and memory reference.
  • The runtime either interprets the validated module or compiles it to native code, at load time or ahead of time.
  • Instantiation allocates the linear memory at its declared minimum size, initialises data and element segments, and binds every import to a host-provided value.
  • Execution proceeds over typed locals and an operand stack per call frame; loads and stores are bounds-checked against the current memory size, often using guard pages so the hardware performs the check.
  • Indirect calls index the table and check the callee's type against the call site's declared type, trapping on mismatch.
  • A trap — an out-of-bounds access, an integer division by zero, an unreachable instruction — unwinds to the host rather than terminating a process.

How it breaks

What the engineer observes when it goes wrong — not what goes wrong internally.

  • A module fails validation on one runtime and loads on another, because the two implement different proposal sets — a hard failure with an error message about an unknown opcode.
  • Memory usage plateaus at an early peak for the life of an instance, because linear memory grows and never shrinks, and a long-lived instance never gives it back.
  • A workload is far slower than the native version and profiling shows the time in host-boundary crossings and data copying rather than in compiled code.
  • A module with irreducible control flow is noticeably larger and slower than the same source compiled natively, because the backend had to duplicate blocks or add a dispatch loop.
  • Someone concludes that compiling C to WebAssembly made it memory-safe, and a heap overflow inside the module corrupts the module's own data exactly as it would natively.
  • An untrusted module runs forever and the host has no timeout or fuel limit configured, because nothing in the format prevents non-termination.

When it helps

  • Running code you did not write, where the boundary guarantee is the actual requirement and everything else is secondary.
  • Multi-tenant execution, where instances are cheap and isolated from each other by construction rather than by configuration.
  • Reasoning about what a sandbox does and does not promise, which is where most WebAssembly security claims go wrong.
  • Understanding a size or performance surprise in generated Wasm, which is often traceable to structured control flow or the shadow stack.

When it hurts

  • Chatty host interaction, where the crossing and copying dominate anything the compiled code does.
  • Languages built on addressable locals, deep recursion or precise garbage collection, all of which need extra machinery to fit the model.
  • Treating the sandbox as memory safety for the code inside it. It is a boundary guarantee, and the module's own bugs are unaffected.

What it costs

Every one of these is paid by something.

  • A stack-machine encoding buys a compact binary that downloads and validates quickly, and pays with a format that says nothing about registers, so every serious runtime must do real code generation to get performance.
  • Structured control flow buys single-pass validation with a linear cost and no pathological cases, and pays by pushing structure reconstruction onto every compiler and by making irreducible control flow cost code size or a dispatch loop.
  • Non-addressable typed locals buy cheap validation and no aliasing analysis, and pay with a shadow stack for every address-taken variable in languages that assume one.
  • A single bounds-checked linear memory buys a boundary guarantee a host can rely on, and pays with a bounds check on every access — usually reduced to a guard-page fault, but never free — and with memory that only ever grows.
  • Capability-by-import buys a sandbox with no ambient authority to misconfigure, and pays by making every host integration explicit work, including things a native program takes entirely for granted.

What else you could do

What a different compiler or language does instead, and when that is better.

  • A register-based bytecode encodes fewer instructions for the same work and is larger per instruction; the trade is the same one [[stack-vs-register-vm]] describes.
  • Unstructured control flow with a verifier, as the JVM has, allows arbitrary jumps and requires dataflow-based verification with a fixed-point computation — more expressive, much more expensive to check.
  • Software fault isolation applies similar guarantees to native code by instrumenting it, avoiding a new format at the cost of the instrumentation and a much harder correctness argument.
  • Operating-system process isolation gives stronger guarantees, including termination and resource limits, at the cost of milliseconds of startup and megabytes of footprint — see [[the-loader]].
  • A managed language runtime provides memory safety inside as well as at the boundary, at the cost of committing to one language and shipping a much larger runtime.

See it for yourself

The flag, dump or tool that shows you this directly.

  • wasm2wat module.wasm shows the text form, which is where the structured control flow becomes visible as nesting rather than as jumps.
  • wasm-objdump -x lists the sections, including the declared memory minimum and maximum and every import the module requires.
  • wasm-validate runs validation alone, which separates "this module is malformed" from "this runtime lacks a proposal".
  • twiggy top module.wasm attributes size to functions and sections, which usually explains a surprising binary size in one screen.
  • Browser DevTools show the linear memory as a byte buffer and let you break inside the module, which makes the shadow stack visible.
  • wasmtime --wasm-features and equivalent runtime flags list which proposals a given runtime and version will accept.

Plausible wrong readings

Stated the way a confident engineer states them.

  • "WebAssembly is memory-safe." Its *boundary* is safe: a module cannot reach outside its own memory. A C program compiled to it can still overflow its own buffers and corrupt its own state.
  • "It is a stack machine, so it must be slow." The stack machine is the encoding. Runtimes compile it to register machine code, and the operand stack does not exist at run time.
  • "No goto means the language is limited." It means the compiler has to reconstruct structure, sometimes at a cost in size or speed. Any computable function is still expressible.
  • "The sandbox prevents malicious code from doing damage." It prevents reaching outside the granted capabilities. If the host granted filesystem access, the module has filesystem access.
  • "Memory can be freed with memory.grow." Linear memory grows and never shrinks. Freeing inside the module is the module's allocator returning bytes to its own heap, not to the host.

Misconceptions

The claim, and what is actually true.

Validation is expensive, so large modules are slow to start.
Validation is a single linear pass and is cheap. What costs time at load is compiling the module to native code, which is a separate step and one that ahead-of-time compilation can remove entirely.
Structured control flow means WebAssembly cannot express real programs.
It expresses anything computable. What it costs is that a compiler must reconstruct structure from a control-flow graph, and irreducible flow needs duplication or a dispatch loop.
Each module gets its own address space like a process.
It gets a linear memory that it alone can address. That is a stronger isolation for reaching outward and a weaker one internally: there are no pages, no permissions and no guard pages inside it.
A sandboxed module cannot exhaust the host.
It can loop forever and it can grow memory to its declared maximum. Limiting that is the host's job — fuel, epochs, timeouts and memory caps — and nothing in the format does it.

Go deeper

The same idea at increasing depth. Stop wherever it stops being useful.

overview

WebAssembly's machine has typed local variables, a working stack, and one big array of bytes it can read and write. Its loops and branches must nest, like while and if in a normal language, rather than jumping anywhere. That nesting lets a host check the whole module for correctness in a single pass before running it. And the module can only touch its own byte array and whatever functions the host handed it, which is what makes running someone else's code reasonable.

practical

Three practical consequences. Boundary crossings are expensive and copies are expensive: design the interface as a few coarse calls rather than many fine ones, and measure the boundary separately from the code. Linear memory never shrinks, so an early peak is permanent for that instance — plan capacity around the peak. And check the proposal set of every runtime you target before relying on threads, SIMD or exceptions, because a mismatch is a validation failure at load rather than a graceful fallback.

advanced

The design is best read as a sustained answer to one question: what must a code format give up so that a host can prove things about it in linear time? Structured control flow, non-addressable locals, a single bounds-checked memory and explicitly typed imports are all answers to that, and each has a matching cost paid by the compiler upstream — stackification, a shadow stack, bounds checks, and no ambient authority. The instructive comparison is the JVM, which made the opposite call on control flow: it permits unstructured jumps and pays for it with a bytecode verifier that performs a genuine dataflow analysis with a fixed-point computation, plus a history of verifier bugs that were exploitable precisely because the analysis is intricate. WebAssembly traded expressiveness in the format for a verification argument simple enough to be mechanically proven sound — and the fact that the specification comes with such a proof, rather than with a prose description, is itself the clearest statement of what the design was optimizing for.

How much this depends on

Nothing in this domain is true of every compiler. These say how much.

specThe stack machine, structured control flow, linear memory semantics, the import model and the validation algorithm are all fixed by the WebAssembly specification, which is unusually precise — it has a formal semantics and a mechanised proof of type soundness. What the specification does not fix is execution strategy or performance, so a module's behaviour is portable and its speed is not.
implementationBounds checking is a semantic requirement, not an instruction-level one: most 64-bit runtimes reserve a large guard region so the hardware's memory protection performs the check with no explicit compare, while 32-bit and embedded runtimes emit real checks. The guarantee is identical; the cost is not, and it is a runtime and target property rather than a property of the format.
implementationPost-1.0 proposals — threads, SIMD, exception handling, tail calls, reference types, garbage collection, the component model — are implemented on different schedules by different runtimes, and using one produces a module that fails validation on hosts that lack it. Portability claims must name the proposal set, not just the format.

If you were asked this in an interview

  • Why does WebAssembly have no goto, and what does that buy?
  • A C program compiled to WebAssembly has a heap buffer overflow. What does the sandbox prevent, and what does it not?
  • What does a WebAssembly module have access to when it starts, and how does that change?

Connections

Domains that do not exist yet
  • Operating Systems — Guard pages, page protection and how hardware performs a bounds check for free
    The usual implementation of linear-memory bounds checking is a large reserved region whose surrounding pages are unmapped, so the hardware traps instead of the compiler comparing. That mechanism belongs to virtual memory; this lesson owns only the semantic guarantee it implements.
  • Programming Languages & Runtime Internals — Running a garbage-collected language inside a Wasm instance
    Before WasmGC a managed language had to ship its own collector inside its linear memory, with no visibility into the host's. How a collector works and what it needs from its environment is owned there; the compiler-side question of what the target can express is ours.