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

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.

Control idioms and how to write them in WAT Common control-flow patterns from C-like languages and the WAT construct that expresses each. pattern WAT construct if / else with a value (if (result t) cond (then …) (else …)) while loop block $exit + loop $next + br_if $exit + br $next do-while loop loop $next … br_if $next break out of nested loops br to the outer block's label early return return (or br to the function label) switch on a small integer nested blocks + br_table

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)))))
Where each branch in the nested search goes br_if $cols jumps back to the start of the inner loop, br_if $rows jumps back to the start of the outer loop, and br_if $found jumps forward past the end of the outer block, leaving both loops at once. br_if $cols back to inner loop start br_if $rows back to outer loop start br_if $found forward past both loops fall through search finished, not found

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 $next or a br_if at the bottom.
  • Branching to the loop to exit. Branching to a loop continues it; exits target the enclosing block.
  • Arms of a valued if with 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.

Dispatch cost, br_table versus an if-chain Nanoseconds per dispatch for an eight-way choice written with br_table and with a chain of if/else comparisons, with uniformly distributed case values, in Chrome. ns per dispatch (lower is better) br_table (jump table) 1.7 ns if / else-if chain 2.4 ns

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.

← Back to WebAssembly Text Format (WAT) Basics