Writing Your First WAT Module by Hand
This tutorial builds a complete WebAssembly module from scratch in WebAssembly Text Format — one function that adds two integers and one that writes into linear memory — then compiles it with wat2wasm and calls both from JavaScript, verifying the output down to the binary’s magic header.
Writing a module by hand is worth an hour precisely because you will not do it often. Every layer
above — Rust, C++, a bundler, generated glue — hides the same small set of facts: a module is a list
of declarations, a function is a stack program, and everything that crosses to JavaScript is a
number. Building one from nothing makes those facts concrete, and afterwards compiler output and
wasm2wat listings read as familiar material rather than as noise.
Prerequisites
- [ ]
wabtinstalled (brew install wabt,apt install wabt, ornpm install -g wabt), giving youwat2wasm - [ ]
wat2wasm --versionprints 1.0.30 or newer - [ ] Node.js 18+ to load the compiled module (
node --version) - [ ]
xxdavailable to inspect the binary header (standard on macOS and most Linux distros) - [ ] A directory to work in and a text editor
Procedure
1. Write the add function
Create first.wat with a module exporting a single function. It takes two i32 parameters and returns their sum. Note the explicit (result i32) — without it the function promises no return value.
(module
(func (export "add") (param $a i32) (param $b i32) (result i32)
local.get $a
local.get $b
i32.add))
2. Add a function that uses memory
Extend the module with a page of linear memory and a function that stores a byte at a given offset, then reads it back. Exporting the memory lets JavaScript see the same bytes.
(module
(memory (export "memory") 1) ;; 1 page = 64 KiB
(func (export "add") (param $a i32) (param $b i32) (result i32)
local.get $a
local.get $b
i32.add)
;; store byte $val at offset $ptr, then load it back as the result
(func (export "poke") (param $ptr i32) (param $val i32) (result i32)
local.get $ptr
local.get $val
i32.store8 ;; pops (ptr, val), writes one byte to memory
local.get $ptr
i32.load8_u)) ;; pushes the byte back as an i32 result
3. Compile with wat2wasm
Run the compiler. Silent success means first.wasm is on disk.
wat2wasm first.wat -o first.wasm
4. Load and call from JavaScript
Create run.mjs. There are no imports to satisfy, so the import object can be omitted. Call add, then use poke and confirm the byte landed in the exported memory.
import { readFile } from "node:fs/promises";
const bytes = await readFile("./first.wasm");
const { instance } = await WebAssembly.instantiate(bytes);
const { add, poke, memory } = instance.exports;
console.log("add(2, 3) =", add(2, 3));
console.log("poke(0, 65) =", poke(0, 65));
const view = new Uint8Array(memory.buffer);
console.log("memory[0] =", view[0]); // 65 -> 'A'
5. Run it
node run.mjs
Expected output
The console shows the two results and the byte read back from memory:
add(2, 3) = 5
poke(0, 65) = 65
memory[0] = 65
To confirm the compiler produced a real WebAssembly binary, inspect the first bytes. Every .wasm file starts with the four-byte magic number 00 61 73 6d (the ASCII for \0asm) followed by the version 01 00 00 00:
xxd first.wasm | head -n 1
00000000: 0061 736d 0100 0000 0107 0160 027f 7f01 .asm.......`....
The 0061 736d is the magic header and 0100 0000 is the version — proof the module is well-formed at the byte level.
Reading the stack as you write
The single skill that makes hand-written WAT feel manageable is tracking the stack in your head — or,
better, in a comment beside each line. Every instruction has a declared effect: local.get pushes one
value, i32.add pops two and pushes one, i32.store pops two and pushes nothing. A function whose
declared result is i32 must end with exactly one value on the stack; anything else is a validation
error before the module ever loads.
(func $sum3 (param i32 i32 i32) (result i32)
local.get 0 ;; stack: [a]
local.get 1 ;; stack: [a, b]
i32.add ;; stack: [a+b]
local.get 2 ;; stack: [a+b, c]
i32.add) ;; stack: [a+b+c] ← exactly one i32, as declared
Two failure shapes follow directly from this. “type mismatch: values remaining on stack at end of
function” means you pushed more than the signature promised — often a forgotten drop after a call
whose result you ignored. “type mismatch: expected i32, found []” means the stack ran dry, usually
because an operand was consumed by an earlier instruction you did not account for. Both are mechanical
once you write the stack state down, and both are invisible if you do not.
Gotchas
- Missing
(result i32). Drop the result clause but still leave a value on the stack andwat2wasmfails withtype mismatch in function, expected [] but got [i32]. The function signature must declare every value the body leaves on the value stack. Add(result i32)back. - Stack not empty at function end. If you
local.get $ptran extra time,wat2wasmreportstype mismatch in function, expected [i32] but got [i32 i32]— two values remain where the signature promised one. Remove the stray push so the stack ends with exactly the declared result. - Export not found. Calling
instance.exports.ad(2, 3)when you exported"add"throwsTypeError: instance.exports.ad is not a function. Exports bind by exact string; check spelling withObject.keys(instance.exports). - Wrong store/load pairing. Using
i32.store(4 bytes) but reading back withi32.load8_u(1 byte), or vice versa, gives a value you did not expect rather than an error. Match the width on both sides — here both are the8-bit variants.
Extending the module once it runs
The natural next step is to make the module touch memory, because that is where the interesting interop questions start. Adding a memory declaration and a store instruction turns the example from a pure function into something JavaScript can read results out of, and it introduces the two ideas that every larger module depends on: an offset is just an integer, and the host reads those bytes through a view over the same buffer.
(module
(memory (export "memory") 1)
(func (export "store_sum") (param i32 i32)
i32.const 0 ;; destination offset
local.get 0
local.get 1
i32.add
i32.store)) ;; writes 4 bytes at offset 0
From JavaScript the result is read with a typed array over the exported memory rather than from a
return value, which is exactly the pattern every real module uses for anything larger than a scalar.
Two details are worth noticing while the example is still small enough to hold in your head. The
store takes its address from the stack like any other operand, so the order of the i32.const and
the operands matters. And the memory must be exported for JavaScript to see it at all — a module can
have memory that the host cannot read, which is occasionally what you want.
Once that works, the third step is an imported function, which completes the picture: a module that computes, stores, and calls back out to the host. At that point you have exercised every mechanism the larger guides in this area build on, in about thirty lines of code you wrote yourself.
Performance note
A module this small compiles to roughly 60 bytes of binary and instantiates in well under a millisecond — the magic header and section framing dominate the size, not the two functions. Hand-written WAT carries no allocator or runtime, so for tiny numeric kernels it produces dramatically smaller binaries than a compiler that bundles malloc/free and panic infrastructure. That size advantage is exactly why hand-WAT is worth reaching for on a single hot function, even though it does not scale to whole programs.
Frequently Asked Questions
Why didn’t I need an import object this time?
The module declares no import fields, so there is nothing for the host to supply. WebAssembly.instantiate(bytes) works with no second argument. You only pass an import object when the module imports host functions or memory.
Why is i32.store8 used instead of i32.store?
i32.store8 writes a single byte, which is what you want when poking one character value. i32.store writes four bytes (a full i32) starting at the offset. Pair each store with the matching load width — i32.load8_u reads one byte back as an unsigned i32.
Can I run this in a browser instead of Node?
Yes. Serve first.wasm over HTTP and use WebAssembly.instantiateStreaming(fetch("/first.wasm")). The only difference is the fetch; the export calls and memory view are identical.
Related
- Wasm binary format deep dive — what the bytes after the magic header mean.
- Stack vs heap execution model — the value stack and
linear memorythis module uses. - Wasm instantiation lifecycle — how the host loads and binds a compiled module.
← Back to WebAssembly Text Format (WAT) Basics