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.

The four parts of a minimal module A module declaration wraps everything. Inside it, a memory declaration reserves linear memory, a function declares its parameters and result, its body is a stack program, and an export names what JavaScript may call. (module …) (memory (export "memory") 1) one page — 64 KiB — and JS can see it (func $add (param i32 i32) (result i32) …) signature declared before the body local.get 0 · local.get 1 · i32.add the body: push, push, operate (export "add" (func $add)) without this, JavaScript sees nothing

Prerequisites

  • [ ] wabt installed (brew install wabt, apt install wabt, or npm install -g wabt), giving you wat2wasm
  • [ ] wat2wasm --version prints 1.0.30 or newer
  • [ ] Node.js 18+ to load the compiled module (node --version)
  • [ ] xxd available 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.

Tracing stack depth through a body Each instruction changes the stack depth by a known amount. Plotting the depth as you read makes both common validation errors obvious: ending above the declared result count, or dipping below zero mid-body. depth local.get 0 local.get 1 i32.add local.get 2 i32.add depth 1 — the declared result ending above this line fails validation Write the depth in a comment while you author; it converts a class of confusing errors into arithmetic you can check by eye.

Gotchas

  • Missing (result i32). Drop the result clause but still leave a value on the stack and wat2wasm fails with type 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 $ptr an extra time, wat2wasm reports type 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" throws TypeError: instance.exports.ad is not a function. Exports bind by exact string; check spelling with Object.keys(instance.exports).
  • Wrong store/load pairing. Using i32.store (4 bytes) but reading back with i32.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 the 8-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.

What hand-written WAT is genuinely for Hand-authored modules are excellent as test fixtures, probe modules for feature detection, and minimal reproductions for engine bugs. They are a poor choice for application logic, where a compiler produces better code and far more maintainable source. worth writing by hand test fixtures with exact, known bytes probe modules for feature detection minimal reproductions for an engine bug cases where control beats convenience not worth writing by hand application logic of any size anything needing strings or structs code more than one person maintains a compiler wins on both speed and clarity The skill transfers even when the artifact does not: reading compiler output fluently is the lasting benefit.

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.

← Back to WebAssembly Text Format (WAT) Basics