Under the Hood
Wasm

Linear memory: the flat array everything lives in

The stack machine from the last lesson can compute a sum, but it has nowhere to keep a string, a struct, or an array — linear memory is that place, a single contiguous block of bytes addressed by plain integers, and this lesson builds it from first principles the same way the last one built the stack.

Linear memory: the flat array everything lives in

The previous lesson built a stack machine that can genuinely compute — push 3, push 4, add, get 7. But look at what it left dangling. The operand stack is transient: values get pushed, consumed, and gone within a few instructions. There is no instruction anywhere in the core set for "keep this array of a thousand integers around for the next ten seconds." A stack is scratch space, not storage. Every real program needs the second thing, and WebAssembly's answer to it is one of the cleanest ideas in the whole design.

That answer is linear memory: a single, contiguous, resizable array of raw bytes that a WASM module reads and writes using plain integer offsets. Address 0, address 1, address 2, all the way up. No objects, no references, no pointers to anything managed — just a byte array and instructions that say "read some bytes starting here" or "write some bytes starting here." This lesson builds that model up mechanically, because once it clicks, it explains simultaneously why C and Rust compile to WASM so naturally, and how JavaScript on the outside gets at anything a WASM module produces on the inside.

One array, addressed by integers

Picture the least imaginative data structure possible: a single ArrayBuffer, say a few hundred kilobytes long, full of zeroes. That is, almost without exaggeration, linear memory. It has no structure of its own — no notion of "this region is a string" or "that region is an object." It's just bytes at positions. Whatever structure exists is something the compiled program imposes on it by convention, and enforces by only ever reading back the offsets it wrote.

WASM gives you a small family of instructions to move data in and out of this array. The two you'll use constantly:

;; store the i32 value 258 at byte offset 1024 in linear memory
i32.const 1024   ;; push the address
i32.const 258     ;; push the value to store
i32.store         ;; pop both, write 4 bytes at offset 1024

;; load it back
i32.const 1024   ;; push the address
i32.load          ;; pop the address, push the 4 bytes read back as an i32

Notice the shape: i32.store and i32.load both take an address as an operand — an address that arrives on the stack exactly like any other value, because to the stack machine it is just another number. There's no special "pointer" type. An address is an i32, arithmetic on it is ordinary i32 arithmetic, and the load/store instructions are the only thing that gives certain integers the meaning "a place in memory."

There's a family of these for different widths and interpretations: i32.load8_u reads a single byte and zero-extends it to 32 bits (useful for reading a raw byte, like one character of a string), i32.load16_s reads two bytes and sign-extends them, i64.store writes 8 bytes, and so on. Every one of them reduces to "read or write N bytes starting at this offset" — the differences are just how many bytes and how to interpret the sign.

Endianness: where those bytes actually go

Storing "the value 258" at offset 1024 begs a question: 258 doesn't fit in one byte, so which of the 4 bytes at offsets 1024, 1025, 1026, 1027 holds which part of it?

WebAssembly linear memory is little-endian: the least-significant byte is stored at the lowest address. Write 258 as a 32-bit value and it looks like this in hex: 0x00000102. Split into bytes from most-significant to least-significant, that's 00 00 01 02. Little-endian storage writes them in the reverse order, low byte first:

Offset1024102510261027
Byte02010000

Read it back with i32.load at offset 1024, and the engine knows to reassemble those four bytes low-byte-first into 258 again. This isn't a WASM-specific quirk — it matches x86 and ARM's native little-endian layout, which is exactly the point: WASM's memory format is chosen so a real CPU's load and store instructions can execute it almost directly, with no byte-swapping tax.

Try it yourself before moving on — store a few values, watch which byte lands where.

Store width
Offset (4)
Value
000
100
200
300
400
500
600
700
800
900
1000
1100
1200
1300
1400
1500
1600
1700
1800
1900
2000
2100
2200
2300
2400
2500
2600
2700
2800
2900
3000
3100
bytes 00 00 00 00 at offset 4 = 0

Linear memory is just a flat array of bytes addressed by integer offset — no types, no structure, only this grid of 32 cells. Storing an i32 writes its four bytes low-byte first (little-endian): the least-significant byte lands at offset, the most-significant at offset + 3. Loading just runs the same rule backwards. This flat, typeless model is why C and Rust map onto WASM so directly, and why JS can read WASM's output by laying a typed array over the very same buffer.

Why this is exactly C's and Rust's memory model

Here's the payoff for all that byte-counting: this flat, offset-addressed array is not a WASM invention grafted onto languages that don't expect it — it is the mental model C and Rust already use. A C heap, underneath whatever malloc does for you, is a big array of bytes plus an allocator that hands out offsets into it and keeps a ledger of which ones are free. A C pointer is, at the hardware level, nothing more than an integer index into that array. That's not an analogy; it's what a pointer has always been on a real machine.

So when a compiler targets WASM, a C pointer becomes a WASM i32 — an offset into linear memory — and *ptr becomes i32.load at that offset. Nothing about the language's model has to bend to fit the target, because the target already is that model. This is the concrete mechanism behind the "WASM is a compile target" idea from the last lesson, and it's exactly why C, C++, and Rust — languages with manual, flat memory management — produce WASM that's small and maps almost one-to-one onto what the source code says. The toolchain lesson picks this up from the compiler's side.

Higher-level data is just bytes at an agreed-upon layout

Once you accept "memory is bytes at offsets," every higher-level data structure falls out as a convention for arranging those bytes:

  • A struct is its fields, laid out one after another (with some padding for alignment) starting at some base offset. A struct { i32 x; i32 y; } at offset 2000 is just: read 4 bytes at 2000 for x, read 4 bytes at 2004 for y. There's no struct instruction in WASM — "struct" is a fact the compiler remembers and you don't see it anywhere in the emitted bytecode.
  • A string is its raw bytes (say, UTF-8) sitting somewhere in memory, plus a length carried alongside it — either as a separate number or via a convention like a null terminator. "Pass a string to a function" in WASM terms means "pass two i32s: the offset where the bytes start, and how many bytes there are."
  • An array is its elements packed back-to-back, each one element_size bytes after the last, so element i lives at base + i * element_size — the exact arithmetic your compiler generates for arr[i].

This is why the earlier lesson's claim — "you don't pass a string as a value, you lay it out as bytes and pass a number that points at it" — is not a workaround bolted onto WASM. It's the direct consequence of a value system that only has four numeric types and a memory model that's just bytes. Every "rich" type you've ever used in a language is, at this level, a layout convention over that flat array.

Bounds-checked and sandboxed

A raw byte array addressed by arbitrary integers sounds like exactly the kind of thing that should be a security disaster — and it would be, except for one guarantee the engine enforces on every single load and store: every access is bounds-checked against the memory's current size. Try to i32.load at an offset past the end of the module's memory, and the instruction doesn't read garbage or corrupt adjacent data — it traps, immediately halting execution with an error the host can catch.

That guarantee is also what makes WASM's sandbox actually a sandbox. A module's linear memory is its own private array; there is no instruction that lets it name an address in some other module's memory, or in the host process's memory, or in the browser's internal data structures. "Access memory" in WASM only ever means "access an offset within this module's own ArrayBuffer," full stop. Compare that to a native buffer overflow in C, where an out-of-bounds write can silently smash whatever happens to sit past the end of an array — that entire bug class is unrepresentable in WASM, because the engine checks the bound on every access rather than trusting the compiled code to stay in range.

Memory can grow, but only in 64KB pages

Linear memory isn't a fixed size chosen once and locked in — a module can request more of it while running, using the memory.grow instruction. But it grows in fixed-size chunks called pages, and a WASM page is defined as exactly 64KB (65,536 bytes). Ask for more memory and you're asking for some whole number of additional pages; there's no such thing as growing by an arbitrary handful of bytes. memory.grow 1 requests one more page and returns the previous size in pages (or -1 if the host refuses, say because it would exceed a configured maximum) — and growth is one-directional. There's no memory.shrink; once a module has claimed a page, it keeps it for the rest of its life.

The same array, seen from JavaScript

Everything above described memory from inside the WASM module. From the JavaScript side, that same block of bytes shows up as a WebAssembly.Memory object, and its .buffer property is a perfectly ordinary ArrayBuffer — the same type you'd get from fetch().arrayBuffer() or any other JS API. JavaScript reads and writes it the same way it reads any ArrayBuffer: by wrapping it in a typed array.

// memory is a WebAssembly.Memory, e.g. from instance.exports.memory
const bytes = new Uint8Array(memory.buffer);

// read the same little-endian i32 the WAT example stored at offset 1024
const view = new DataView(memory.buffer);
const value = view.getInt32(1024, /* littleEndian */ true);
console.log(value); // 258

There is no translation layer, no serialization, no copying into some JS-friendly representation — bytes and view are looking at literally the same bytes the WASM module just wrote. This is the entire mechanism behind passing "big" data across the JS/WASM boundary: WASM writes bytes at an offset, hands JS the offset (as a plain number, because that's all a WASM function can return), and JS reads the bytes back out of memory.buffer at that offset. No object graph ever crosses the boundary — only integers and the shared array they index into. That's the exact machinery the next lesson builds out into passing strings, arrays, and structured data both directions.

Where this goes next

You now have both halves of the execution model: a stack machine that computes, from the last lesson, and linear memory that stores, from this one. Every remaining piece of the WASM story is built from these two. Compiling to WASM picks up the first thread you might already be wondering about — given that C and Rust already think in flat, offset-addressed memory, what does the actual pipeline from source code to a .wasm binary look like, and why do garbage-collected languages have a much rockier time fitting the same mold?

Go deeper

Check yourself

Answer out loud, as if an interviewer asked. If you hand-wave, reread that section.

  1. Why does the stack machine from the previous lesson need linear memory at all — what can't an operand stack express?
  2. Walk through storing the i32 value 258 at offset 1024: which byte value ends up at offset 1024, and why is that the 'low' byte?
  3. Explain why a C pointer maps so directly onto a WASM i32 offset — what's the underlying similarity in memory models?
  4. How does WASM represent a struct with two i32 fields, given that there's no struct instruction anywhere in the instruction set?
  5. What happens if compiled code tries to i32.load at an offset past the end of the module's memory, and why is that behavior the basis of WASM's sandboxing?
  6. Why does memory.grow add memory in fixed 64KB pages instead of an arbitrary byte count, and can a module ever give memory back?
  7. A JS variable holds a WebAssembly.Memory. What concrete steps turn that into a JS-readable view of the same bytes a WASM module just wrote?