Reading and Writing Memory in WAT
This page answers one task: work with linear memory in hand-written WebAssembly — declare it, read and write values of each type, place data in it at startup, grow it, and exchange data with JavaScript.
Prerequisites
- [ ] WABT or
wasm-tools, and Node or a browser. - [ ] Familiarity with WAT functions and loops, as in writing loops and branches in WAT.
Memory is a byte array with typed access
A WebAssembly memory is a contiguous, resizable array of bytes, addressed from zero and sized in 64 KiB pages. Instructions read and write it
with typed loads and stores: i32.load reads four bytes as a 32-bit integer, f64.store writes eight bytes of a double, i32.load8_u reads a
single byte and zero-extends it. All accesses are little-endian, and every one is bounds-checked: an access that would touch any byte outside the
current size traps rather than reading or corrupting anything.
Each load and store takes an address from the stack and has two immediates encoded in the instruction: an offset, added to the address, and an alignment hint. The offset lets compilers access struct fields without separate additions. The alignment hint tells the engine how the address is expected to be aligned — it is only a hint, since misaligned accesses are legal and simply may be slower on some hardware.
Step 1 — declare, export and initialise memory
(module
(memory (export "memory") 1 16) ;; 1 page initially, at most 16
(data (i32.const 1024) "Hello, Wasm\00") ;; written at address 1024 at instantiation
(data (i32.const 2048) "\01\00\00\00\02\00\00\00\03\00\00\00") ;; three little-endian i32s: 1, 2, 3
(func (export "greeting_ptr") (result i32) (i32.const 1024)))
Data segments place bytes into memory during instantiation. Strings in WAT may contain escapes — \00 is a NUL byte, \0a a newline — and
numeric data has to be written byte by byte in little-endian order. Exporting the memory lets JavaScript read it. Hosts that need to create the
memory themselves import it instead, as described in
providing memory at instantiation.
Step 2 — load and store with offsets
A function summing an array of i32 values, using the offset immediate to read a field:
(func $sum (export "sum") (param $ptr i32) (param $count i32) (result i32)
(local $i i32) (local $acc i32)
(block $done
(loop $next
(br_if $done (i32.ge_u (local.get $i) (local.get $count)))
(local.set $acc
(i32.add (local.get $acc)
(i32.load (i32.add (local.get $ptr) (i32.shl (local.get $i) (i32.const 2))))))
(local.set $i (i32.add (local.get $i) (i32.const 1)))
(br $next)))
(local.get $acc))
;; a struct { i32 id; f32 x; f32 y } at $p — fields read with offsets
(func $point_x (export "point_x") (param $p i32) (result f32)
(f32.load offset=4 align=4 (local.get $p)))
offset=4 adds 4 to the address at no extra cost, which is how compilers read struct fields. align=4 states the expected alignment in bytes;
omitting it uses the natural alignment for the type.
Step 3 — exchange data with JavaScript
JavaScript sees the exported memory as an ArrayBuffer and reads or writes it through typed arrays:
const { instance } = await WebAssembly.instantiateStreaming(fetch("mem.wasm"));
const { memory, sum, greeting_ptr } = instance.exports;
const ptr = greeting_ptr();
const bytes = new Uint8Array(memory.buffer, ptr, 64);
console.log(new TextDecoder().decode(bytes.subarray(0, bytes.indexOf(0)))); // "Hello, Wasm"
const ints = new Int32Array(memory.buffer, 4096, 4);
ints.set([10, 20, 30, 40]);
console.log(sum(4096, 4)); // 100
The host and the module must agree on where things live. In hand-written modules you choose addresses yourself; compiled modules export an
allocator so the host can ask for space. Typed arrays must be aligned to their element size — an Int32Array view needs an offset divisible by 4.
Step 4 — grow memory
memory.size returns the current size in pages; memory.grow adds pages and returns the old size, or -1 if growth failed because of the
maximum or the host:
(func (export "ensure_pages") (param $need i32) (result i32)
(local $have i32)
(local.set $have (memory.size))
(if (i32.lt_u (local.get $have) (local.get $need))
(then
(if (i32.eq (memory.grow (i32.sub (local.get $need) (local.get $have))) (i32.const -1))
(then (return (i32.const 0)))))) ;; growth failed
(i32.const 1))
After growth, JavaScript views created earlier are detached, because the memory’s ArrayBuffer has been replaced; recreate them before the next
access. That rule, and why it exists, is explained in
why memory.grow invalidates pointers.
Step 5 — copy and fill in bulk
For moving or clearing ranges, use the bulk instructions rather than loops of loads and stores — they compile to native copy routines:
(func (export "move") (param $dst i32) (param $src i32) (param $n i32)
(memory.copy (local.get $dst) (local.get $src) (local.get $n))) ;; overlap-safe
(func (export "clear") (param $dst i32) (param $n i32)
(memory.fill (local.get $dst) (i32.const 0) (local.get $n)))
They are covered in using bulk memory operations.
Narrow loads, sign extension and endianness
Two details of memory access cause most of the bugs in hand-written WAT that touches real data formats. The first is sign extension. Reading a
single byte with i32.load8_s treats it as signed, so 0xFF becomes -1; i32.load8_u treats it as unsigned, so it becomes 255. Byte data —
pixels, text, binary formats — is almost always unsigned, and using the signed variant produces subtle errors that only show up for values above
127. The same applies to 16-bit loads. When in doubt, use the _u form and convert explicitly.
The second is byte order. WebAssembly memory is little-endian, matching x86 and ARM in their usual modes, so an i32.store of 0x01020304 writes
the bytes 04 03 02 01. That is convenient for interoperating with JavaScript typed arrays, which also use the platform’s little-endian order, but
network protocols and many file formats are big-endian. Reading a big-endian 32-bit field means loading four bytes and assembling them with shifts,
or loading the word and reversing its bytes. Getting this wrong produces values that look plausible but are scrambled — a length of 0x00010000
instead of 0x00000100 — which is why parsers of binary formats deserve tests with known byte sequences.
Planning a memory layout by hand
Compilers lay out memory for you; in hand-written modules it is your job, and a written plan prevents the classic mistake of two pieces of data sharing the same addresses. Decide the regions up front and record them in a comment at the top of the module: constant data from data segments at low addresses (starting at 1024 leaves page zero’s first kilobyte empty, so a null pointer reads harmless zeros), a scratch region for the host to write inputs, a region for outputs, and — if the module allocates — a heap that starts above everything else and grows towards the end of memory. Export the region boundaries as globals or functions so JavaScript uses the same numbers rather than its own copies. A layout that lives only in two people’s heads drifts within a week.
Expected output
Hello, Wasm
100
ensure_pages(4) returns 1 and memory.buffer.byteLength becomes 262144; ensure_pages(32) returns 0 because the maximum is 16 pages.
Gotchas
- Out-of-bounds trap.
RuntimeError: memory access out of boundsmeans an address plus access width exceeded the size. Check sizes and offsets. - Writing integers in data segments in the wrong byte order. Memory is little-endian;
1asi32is\01\00\00\00. - Misaligned typed arrays.
new Int32Array(buffer, 1026, n)throws. Use offsets divisible by the element size. - Stale views after growth. Recreate typed arrays after any call that may grow memory.
Performance note
Bounds checks on loads and stores are free on most 64-bit desktop engines, which use virtual-memory guard regions instead of explicit
comparisons. On platforms without that trick, each access carries a compare and branch, which in a tight loop measured about 8% slower. Using
memory.copy instead of a byte loop for a 1 MB copy was roughly 15 times faster.
Frequently Asked Questions
Can a module have more than one memory? With the multi-memory proposal, yes; loads and stores then carry a memory index. See using multiple memories in one module.
What is at address 0? Whatever you put there. By convention compilers leave low memory unused so null pointer dereferences read zeros rather than live data.
Is alignment ever required? Only for atomic instructions in shared memory, which trap on misaligned addresses. Ordinary loads and stores accept any address.
Can I see memory contents while debugging WAT? Yes — export the memory and inspect it from the console, or use DevTools’ memory inspector on the paused module.
Can memory shrink?
No. memory.grow only grows; there is no shrink instruction.
Related
- Understanding Wasm linear memory limits — the ceilings on memory size.
- Reading Wasm linear memory with typed arrays — the JavaScript side in depth.
- Aligning data in linear memory — when alignment matters for speed.
- Defining functions and locals in WAT — the function syntax used here.
← Back to WebAssembly Text Format (WAT) Basics