Understanding Structured Control Flow in Wasm

This page answers one question: WebAssembly has no goto and no jump to an arbitrary address — so how do branches work, how do compilers translate the unstructured control flow of C and Rust into it, and why was it designed this way?

Prerequisites

Blocks, loops and labels instead of addresses

Native machine code jumps to addresses: a branch instruction names a target location, and the CPU continues there. That is powerful and dangerous — code can jump into the middle of an instruction, into data, or to an address computed at runtime from attacker-controlled input, which is how return-oriented programming and many exploits work.

WebAssembly has no addresses for code. Instead, it has structured control constructs that nest like brackets: block … end, loop … end, and if … else … end. Each construct introduces a label, and branch instructions — br, br_if, br_table — name a label by its nesting depth rather than an address. The rule that makes it work is simple and differs by construct: branching to a block jumps forward to its end; branching to a loop jumps backward to its start. There is no other kind of jump.

Where each branch goes A br to a block label continues after the block's end, a forward exit. A br to a loop label continues at the loop's start, a backward jump. An if chooses between its then and else arms. No branch can target anything other than an enclosing label. br to block exit forward to end br to loop jump back to start if / else choose an arm br_table pick a label by index Labels are counted outward from the branch — 0 is the innermost enclosing construct.

Step 1 — read a counted loop

(func $sum_to (param $n i32) (result i32)
  (local $i i32) (local $acc i32)
  (block $exit
    (loop $next
      (br_if $exit (i32.ge_u (local.get $i) (local.get $n)))   ;; if i >= n, leave the block
      (local.set $acc (i32.add (local.get $acc) (local.get $i)))
      (local.set $i (i32.add (local.get $i) (i32.const 1)))
      (br $next)))                                             ;; back to the loop start
  (local.get $acc))

The idiom is a loop inside a block: br $next continues the loop, br_if $exit leaves it. Without a branch at its end, a loop simply falls through — loops do not repeat by themselves. In the binary, $exit and $next become depths: from inside the loop, the loop is label 0 and the block is label 1. The full set of WAT forms is covered in writing loops and branches in WAT.

Step 2 — see how compilers handle arbitrary control flow

C, C++ and Rust compile to control-flow graphs with arbitrary edges — goto, break out of nested loops, early returns, switch fall-through, and whatever the optimizer produces after inlining. LLVM’s WebAssembly backend turns those graphs into nested blocks and loops with an algorithm usually called stackification (or the “relooper” in earlier tools). For reducible control flow — every loop has a single entry — which covers essentially all compiled code, the translation is exact: each forward edge becomes a branch out of a block, each back edge a branch to a loop.

int find(const int *xs, int n, int target) {
  for (int i = 0; i < n; i++) {
    if (xs[i] == target) return i;    // early exit from inside the loop
  }
  return -1;
}
(block $not_found
  (block $found
    (loop $scan
      (br_if $not_found (i32.ge_s (local.get $i) (local.get $n)))
      (br_if $found (i32.eq (i32.load (…address of xs[i]…)) (local.get $target)))
      (local.set $i (i32.add (local.get $i) (i32.const 1)))
      (br $scan)))
  (return (local.get $i)))         ;; reached via br $found
(i32.const -1)                     ;; reached via br $not_found

Irreducible control flow — loops with multiple entries, which hand-written goto can produce — needs a helper variable and a dispatch loop, which compilers generate automatically at a small cost. It is rare enough that you will almost never see it.

Step 3 — understand br_table

br_table branches to one of several labels chosen by an index, with a default. It is how switch statements and match expressions on dense integer values compile:

(block $default
  (block $case2
    (block $case1
      (block $case0
        (br_table $case0 $case1 $case2 $default (local.get $kind)))
      ;; case 0 code
      (br $default))
    ;; case 1 code
    (br $default))
  ;; case 2 code
)

Engines compile br_table to a jump table — a bounds check and an indirect jump within the function — so it is as fast as a native switch. The target is still constrained to the listed labels; there is no way to jump anywhere else.

WebAssembly control instructions and their native equivalents The structured control instructions, what each targets, and what an engine typically emits for it. instruction targets typical machine code br $block end of an enclosing block jmp forward br $loop start of an enclosing loop jmp backward br_if either of the above conditional jump br_table one of N labels by index bounds check + jump table if … else … end then or else arm conditional jump around arm return the function's caller epilogue + ret

Step 4 — see what structure buys for validation and compilation

Because every branch targets an enclosing label and every construct declares the values it consumes and produces, an engine can validate a function in one pass: at each branch it knows exactly what the target expects on the operand stack. There are no unknown jump targets to analyse and no way for control to arrive at an instruction from an unexpected place. Compilers benefit the same way — the structure makes loops and their nesting explicit, which is exactly what optimizations such as loop-invariant code motion and register allocation want.

The same property is a security boundary. Control can only flow to the start of a function (through call or call_indirect, which is type-checked) or along the structured edges inside it. Jumping into the middle of a function, into data, or to a computed address is not expressible, which removes control-flow hijacking as an attack on WebAssembly code itself — a guarantee native code achieves only partially, with extra hardware and compiler features.

Step 5 — read compiler output with structure in mind

When reading disassembled modules, the nesting tells you the original control flow. A loop directly inside a block is almost always a while or for loop with an exit; nested blocks ending in a br_table are a switch; a chain of blocks with br_if exits is usually a sequence of early returns or && conditions. Tools such as wasm-decompile reconstruct C-like code from this structure:

wasm-decompile app.wasm | sed -n '/function find/,/^}/p'

That is often the quickest way to understand what a function in an unfamiliar module does, before reaching for a debugger. The text format details are in converting Wasm back to WAT with wasm2wat.

What structure costs

The design is not free, and it is fair to list what it costs. Compilers need an extra pass to convert control-flow graphs to nested blocks, and irreducible graphs need a dispatch variable. Some low-level techniques used in interpreters and coroutines — computed gotos, jumping into the middle of a loop — must be rewritten as br_table dispatch or state machines, which can be slower; that limitation is part of why proposals for stack switching and tail calls exist. And hand-written WAT for complex control flow is verbose. For the code compilers produce, though, the cost is close to zero, and the benefits — one-pass validation, simple compilation and structurally guaranteed control-flow integrity — are permanent.

Expected output

Assembling and calling the sum_to function:

const { instance } = await WebAssembly.instantiateStreaming(fetch("sum.wasm"));
console.log(instance.exports.sum_to(10));    // 45

Gotchas

  • Expecting a loop to repeat by itself. A loop without a branch back runs once. End with br to continue.
  • Off-by-one label depths. Raw branch depths count outward from the branch; use named labels in WAT.
  • Branch with the wrong stack values. A branch to a block with results must provide them. Validation reports a type mismatch.
  • Forgetting that if has its own label. A branch inside an if arm counts the if as the innermost construct. Name it if you branch out of it.
  • Reading br to a loop as an exit. Branching to a loop label continues the loop; exits target the enclosing block.

Performance note

Structured control flow compiles to the same jumps native code uses. In a benchmark of a switch-heavy bytecode interpreter, the br_table-based dispatch ran within 6% of the native build’s switch; the remaining gap came from bounds checks on memory, not from control flow.

Dispatch loop speed, Wasm br_table versus native switch Instructions per second for a small bytecode interpreter's dispatch loop compiled natively with a switch and compiled to Wasm with br_table, in Chrome on a laptop. million interpreted instructions per second native switch (clang -O2) 612 M/s Wasm br_table (Chrome) 576 M/s

Frequently Asked Questions

Can WebAssembly express a goto at all? Forward goto maps to branching out of blocks; backward goto maps to loops. Arbitrary combinations need the dispatch-loop technique, which compilers apply automatically.

Does structure affect performance on any CPU? Not measurably for compiled code: engines lay out the blocks linearly and emit ordinary jumps. The structure exists in the bytecode, not in the machine code.

Is return a branch? Effectively yes — it leaves the function, equivalent to branching to the function body’s outermost label.

How do exceptions fit in? The exception-handling proposal adds try_table and throw, which are also structured: handlers are labels in enclosing blocks; see exception handling in WebAssembly.

Do tail calls break the structure? No. return_call replaces the current frame with a call to a function’s start — still a structured, type-checked target; see tail calls and deep recursion.

← Back to Stack vs Heap Execution Model