· 5 min read
Straight to the module
Hand-written WebAssembly is a pleasure while the whole contract fits on one screen, and misery the moment it needs an ABI.

Three parameters go in, one value comes out, and the seven instructions in between are every step of the work. That's the whole module. For a leaf calculation this small, a source language wouldn't make anything clearer — it would move those same seven instructions behind a compiler and ask you to trust the round trip.
(module (func (export "lerp") (param $a f32) (param $b f32) (param $t f32) (result f32) local.get $b local.get $a f32.sub local.get $t f32.mul local.get $a f32.add))Writing “straight WASM” usually means writing WebAssembly's standardized text format, WAT, then assembling it into the binary a runtime loads. The WABT toolchain makes that boundary refreshingly dull: run wat2wasm lerp.wat -o lerp.wasm and you're done. The text is the source, the binary is the build artifact, and nobody has to squint at hex.
The text is already a language#
WebAssembly 3.0 was published yesterday, July 10, 2026, and its change history runs long: tail calls, exception handling, multiple and 64-bit memories, typed function references, managed references, more. The format has grown well past its original minimum, but the center of it is still small enough to hold in your head.
The official overview describes a typed stack machine with structured blocks, loops, and conditionals, where functions declare their parameters and results and validation checks the operand stack before anything runs. Linear memory is a bounded byte array — no invisible heap with opinions about your data. A module names exactly what it imports and exports.
And the text format is a real language: it has names, comments, folded expressions, and a grammar maintained right beside the binary one. WABT deliberately stays a full-fidelity converter instead of growing into an optimizing compiler, which means you can commit the readable module, generate the binary in the build, and inspect the round trip without inviting a second semantic universe into the repo.
A compiler earns its place when it carries structure the target can't express comfortably — domain types, ownership rules, generic algorithms, a standard library. When the entire job is seven arithmetic instructions and an export name, there isn't much for it to carry.
The boundary earns the trouble#
Arithmetic is the flattering demo, though. Memory is where writing WebAssembly by hand gets either useful or embarrassing. This module sums a run of 32-bit integers from linear memory:
(module (memory (export "memory") 1) (func (export "sum_i32") (param $ptr i32) (param $len i32) (result i32) (local $sum i32) (block $done (loop $next (br_if $done (i32.eqz (local.get $len))) (local.set $sum (i32.add (local.get $sum) (i32.load (local.get $ptr)))) (local.set $ptr (i32.add (local.get $ptr) (i32.const 4))) (local.set $len (i32.sub (local.get $len) (i32.const 1))) (br $next))) (local.get $sum)))The loop knows nothing about JavaScript arrays — only an address, a length, four-byte loads, and an accumulator. The meaning comes from the host side:
const { instance } = await WebAssembly.instantiateStreaming( fetch("/sum.wasm"),);const { memory, sum_i32 } = instance.exports;const values = new Int32Array(memory.buffer, 0, 4);values.set([4, 8, 15, 16]);sum_i32(0, values.length); // 43The browser's streaming instantiation API compiles the response and supplies the module's imports, and the JavaScript interface hands you the exported function as a callable value and the memory as an ArrayBuffer. Nothing guesses at layout: both sides have agreed that address zero holds four little 32-bit values and that the function gets their count.
This is the good case for hand-written WAT: a checksum, a color transform, a fixed binary decoder, a small deterministic state transition. What these all have in common is a contract you can review in one sitting, without opening any generated glue.
Memory removes the romance#
Change the input from integers to text and the bill arrives. Which encoding does the pointer refer to? Who owns the buffer? May the module retain it? How does a function return a string whose length is unknown? Do errors trap, return a status code, or write a tagged result into memory? No single one of those questions is hard, but answer all of them and you've written an ABI.
Core WebAssembly refuses to invent those answers for you. Its module model describes functions, memories, tables, globals, imports, and exports, and a linear memory is — the spec says so — a list of raw uninterpreted bytes. That restraint is exactly why the format embeds so cleanly, and also why a rich application interface gets tedious to write by hand.
The Component Model's WIT format exists for that next layer: strings, lists, records, variants, results, resources, whole imported and exported worlds, all described in a developer-facing interface definition. Once you need those types, take the generated bindings — they're what keeps allocation and ownership rules from leaking into every call site.
Stop before you become the toolchain#
Hand-writing WebAssembly produces a dangerous little satisfaction, and I keep catching myself chasing it. The first function is exact. The fifth wants constants and shared helpers. By the twentieth you want macros, a test harness, source maps, and a nicer way to spell a record. You haven't escaped the compiler. You've started one without admitting it.
And direct WAT doesn't make anything fast by itself. You get authority over instruction shape, not immunity from host-call overhead, memory traffic, engine compilation, or a bad algorithm. Keep the module because it reads clearer, then benchmark it against the workload you actually have.
So use Rust, or C, or Zig when the program needs a language. But when the module itself is the clearest thing you could write — a lerp, a checksum, a loop over four-byte loads — skip the compiler and write the target. Nobody's asking you to build a browser in parentheses.