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
- [ ] WABT for assembling and disassembling examples.
- [ ] Familiarity with the operand stack, as in how the Wasm operand stack works.
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.
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.
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
loopwithout a branch back runs once. End withbrto 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
ifhas its own label. A branch inside anifarm counts theifas the innermost construct. Name it if you branch out of it. - Reading
brto 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.
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.
Related
- Writing loops and branches in WAT — the constructs hands-on.
- Calling function pointers with call_indirect — the only indirect control transfer.
- Decoding Wasm opcodes for debugging — how blocks and branches are encoded.
- Browser sandbox & security boundaries — the guarantees structure supports.
← Back to Stack vs Heap Execution Model