Writing Loops and Branches in WAT
This page answers one task: express conditionals, loops, early returns and switch statements in hand-written WebAssembly text format, using the structured control instructions the format provides.
Prerequisites
- [ ] WABT (
wat2wasm,wasm-interp) orwasm-tools. - [ ] Comfort with functions and locals in WAT, as in defining functions and locals in WAT.
Four constructs and three branch instructions
WebAssembly has no goto. Control flow is built from four nesting constructs — block, loop, if … else and the function body itself — and
branch instructions that target them by label: br (always), br_if (if the top of the stack is non-zero) and br_table (by index). The rule
that makes it work is simple: branching to a block jumps forward to just after its end; branching to a loop jumps backward to its start.
Labels are counted outward from the branch — 0 is the innermost enclosing construct — and WAT lets you name them so you never count by hand.
The reasoning behind this design is in
understanding structured control flow in Wasm.
Step 1 — if/else, with and without results
An if pops a condition and runs then when it is non-zero. It can produce a value, in which case both arms must leave one of the declared type:
(func $abs (export "abs") (param $x i32) (result i32)
(if (result i32) (i32.lt_s (local.get $x) (i32.const 0))
(then (i32.sub (i32.const 0) (local.get $x)))
(else (local.get $x))))
(func $clamp_low (param $p i32) (param $v i32) ;; no result: only side effects
(if (i32.lt_s (local.get $v) (i32.const 0))
(then (i32.store (local.get $p) (i32.const 0)))))
For simple value selection without branches, select picks one of two values based on a condition and often compiles to a conditional move:
(select (local.get $a) (local.get $b) (local.get $cond)).
Step 2 — a while loop
The idiom is a loop nested inside a block: branch to the loop to continue, to the block to exit.
(func $count_zeros (export "count_zeros") (param $ptr i32) (param $len i32) (result i32)
(local $i i32) (local $n i32)
(block $exit
(loop $next
(br_if $exit (i32.ge_u (local.get $i) (local.get $len))) ;; while (i < len)
(if (i32.eqz (i32.load8_u (i32.add (local.get $ptr) (local.get $i))))
(then (local.set $n (i32.add (local.get $n) (i32.const 1)))))
(local.set $i (i32.add (local.get $i) (i32.const 1)))
(br $next))) ;; back to the top
(local.get $n))
Note the final br $next: a loop does not repeat on its own. Without that branch, execution falls out of the loop after one pass — the most
common mistake in hand-written WAT.
Step 3 — do-while and break from nested loops
A do-while loop needs no outer block: put the condition at the bottom and branch back while it holds.
(loop $again
;; … body runs at least once …
(br_if $again (i32.lt_u (local.get $i) (local.get $n))))
Breaking out of nested loops — the case C needs goto or a flag for — is just a branch to an outer label:
(block $found
(loop $rows
(local.set $c (i32.const 0))
(loop $cols
(br_if $found (i32.eq (call $cell (local.get $r) (local.get $c)) (local.get $target))) ;; exits both loops
(local.set $c (i32.add (local.get $c) (i32.const 1)))
(br_if $cols (i32.lt_u (local.get $c) (local.get $w))))
(local.set $r (i32.add (local.get $r) (i32.const 1)))
(br_if $rows (i32.lt_u (local.get $r) (local.get $h)))))
Step 4 — switch with br_table
br_table branches to one of several labels by an index, with a default for out-of-range values. Nest one block per case; the innermost block is
case 0:
(func $op (export "op") (param $kind i32) (param $a i32) (param $b i32) (result i32)
(block $default
(block $mul
(block $sub
(block $add
(br_table $add $sub $mul $default (local.get $kind)))
(return (i32.add (local.get $a) (local.get $b)))) ;; kind 0
(return (i32.sub (local.get $a) (local.get $b)))) ;; kind 1
(return (i32.mul (local.get $a) (local.get $b)))) ;; kind 2
(i32.const 0)) ;; anything else
Engines compile br_table to a bounds check and a jump table, so it is as efficient as a native switch. The layout — cases in reverse nesting
order — is awkward to write but becomes familiar quickly, and it is exactly what compilers emit for switch and match.
Step 5 — test control flow with the interpreter
WABT’s interpreter can run exports directly, which is a fast way to check edge cases without writing JavaScript:
wat2wasm control.wat -o control.wasm
wasm-interp control.wasm --run-export=op --argument=i32:2 --argument=i32:6 --argument=i32:7
wasm-interp control.wasm --run-export=op --argument=i32:9 --argument=i32:6 --argument=i32:7
op(i32:2, i32:6, i32:7) => i32:42
op(i32:9, i32:6, i32:7) => i32:0
Test each branch, the loop boundaries — zero iterations, one, many — and the default case of every br_table.
How compilers write the same loops
Compilers produce the same constructs, arranged in ways that are worth recognising when you read their output. A C for loop at -O2 is often
rotated: the condition is checked once before the loop and then at the bottom, so the loop body has a single backward br_if instead of a
forward exit at the top and an unconditional branch at the bottom. That saves a branch per iteration and is the shape you will see most often:
(if (i32.gt_s (local.get $n) (i32.const 0)) ;; guard: skip if no iterations
(then
(loop $body
;; … body …
(br_if $body (i32.lt_s (local.tee $i (i32.add (local.get $i) (i32.const 1))) (local.get $n))))))
Compilers also unroll small loops, so a loop that adds four elements per iteration and then handles the remainder in a short second loop is
normal output at higher optimization levels. And switch statements over sparse values become a chain of comparisons or a binary search of
br_ifs rather than a br_table, since a jump table would be mostly empty. None of this changes how the constructs work; it changes which
combinations you see, and recognising the patterns makes disassembly much quicker to read.
Writing control flow that stays readable
Hand-written WAT gets hard to read quickly when control flow is complex, and a few conventions help. Always name labels after what branching to
them means — $exit, $continue, $found, $default — rather than after the construct. Indent nested constructs consistently, with the
condition of an if or br_if on the same line as the instruction. Keep loops small and move their bodies into separate functions when they
grow; the engine will inline small functions where it helps. And comment the C-like intent next to each construct, as the examples above do,
because the structured form expresses how control moves but not always why. When a function’s control flow becomes genuinely complicated, that
is usually the point at which writing it in a compiled language and reading the WAT output is more productive than writing WAT by hand.
Expected output
abs(-7) → 7
count_zeros on [0,3,0,0,9] → 3
op(0, 6, 7) → 13 op(1, 6, 7) → -1 op(2, 6, 7) → 42 op(9, 6, 7) → 0
Gotchas
- A loop that runs once. No branch back to the loop label at the end. Add
br $nextor abr_ifat the bottom. - Branching to the loop to exit. Branching to a
loopcontinues it; exits target the enclosingblock. - Arms of a valued
ifwith different types. Both arms must leave the declared result type. - br_table labels in the wrong order. The list maps index 0 to the first label. Name labels after cases to keep it straight.
Performance note
Structured control flow compiles to ordinary jumps. In a microbenchmark of a dispatch loop, the br_table version ran within a few percent of an
equivalent native switch; a chain of if … else if comparisons with eight cases was about 40% slower for uniformly distributed inputs, because
it needs up to eight comparisons per dispatch.
Frequently Asked Questions
Is there a continue instruction?
br to the loop label is continue; br to the enclosing block is break.
Can a block take parameters?
With multi-value, yes — (block (param i32) (result i32) …) consumes a value from the stack on entry.
Can a br_table have hundreds of cases?
Yes. The label list can be long; the engine still emits a jump table, so dispatch cost does not grow with the number of cases.
What does unreachable do?
It traps immediately. Compilers emit it after calls that never return and for impossible cases; hand-written code can use it as an assertion.
How do I return early from deep inside loops?
Use return, which leaves the function from any depth, leaving the declared results on the stack.
Related
- Understanding structured control flow in Wasm — why it works this way.
- Reading and writing memory in WAT — the loads used in these loops.
- Converting WAT to Wasm with wat2wasm — assembling the examples.
- Decoding Wasm opcodes for debugging — the binary encoding of these instructions.
← Back to WebAssembly Text Format (WAT) Basics