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

  • [ ] 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.

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.

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.

← Back to WebAssembly Text Format (WAT) Basics