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.
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.
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.
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