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

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.

Load and store instructions by width The memory access instructions for each width, the value type they produce or consume, and how narrow loads extend to full width. bytes load store note 1 i32.load8_s / load8_u i32.store8 sign- or zero-extends 2 i32.load16_s / load16_u i32.store16 sign- or zero-extends 4 i32.load, f32.load i32.store, f32.store full 32-bit 8 i64.load, f64.load i64.store, f64.store full 64-bit 16 v128.load v128.store SIMD vectors

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.

JavaScript and the module sharing one memory JavaScript writes four integers into the exported memory through an Int32Array view, calls sum with the address and count, the module reads the values with i32.load and returns the total, and JavaScript reads a string the module placed with a data segment. JavaScript linear memory module Int32Array at 4096: [10, 20, 30, 40] sum(4096, 4) i32.load × 4 100 read bytes at 1024 → 'Hello, Wasm'

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 bounds means 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; 1 as i32 is \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.

Copying 1 MB, byte loop versus memory.copy Time to copy one megabyte inside a module with a loop of i32.load8_u and i32.store8, with a loop of i64 loads and stores, and with a single memory.copy, in Chrome on a laptop. microseconds per 1 MB copy byte loop 610 µs i64 loop 140 µs memory.copy 41 µs

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.

← Back to WebAssembly Text Format (WAT) Basics