Linear Memory Management & Allocators

A WebAssembly module computes against a single contiguous byte array — its linear memory — and nothing else. There is no garbage collector, no mmap, no virtual address space the module can see. If your code needs a heap, something has to carve allocations out of that one buffer, hand pointers back to the caller, and reclaim them later. This guide covers the memory model itself (page granularity, memory.grow, the conventional layout), the allocators that run on top of it (dlmalloc, wee_alloc, bump allocators), how to export alloc/free so JavaScript can drive the heap, and the failure modes — detached views, OOM traps, double frees, and slow leaks — that bite teams shipping Wasm.

Prerequisites

  • [ ] Rust 1.78+ with the wasm32-unknown-unknown target (rustup target add wasm32-unknown-unknown)
  • [ ] wasm-pack 0.12+ and wabt (wasm-objdump, wasm-validate) on your PATH
  • [ ] A browser or node 20+ to instantiate modules and read instance.exports.memory.buffer
  • [ ] Familiarity with the stack-based VM execution model and where the value stack ends and linear memory begins
  • [ ] Comfort reading typed-array views over an ArrayBuffer from JavaScript

The linear memory model

A Wasm memory is declared with an initial size and an optional maximum, both measured in page units of exactly 64 KiB (65,536 bytes). (memory 2 16) means “start with 2 pages — 128 KiB — and allow growth up to 16 pages — 1 MiB.” At instantiation the engine backs that memory with a single ArrayBuffer whose byteLength is initial * 65536. Every address the module ever uses is a byte offset into that one buffer; index 0 is the first byte. This is the entire address space the sandbox exposes, which is why a wild pointer in Wasm traps on a bounds check instead of corrupting the host — the deeper treatment of the size ceiling lives in understanding Wasm linear memory limits.

The memory has no intrinsic structure. The notion of a “stack”, “data section”, and “heap” is purely a convention that the toolchain lays down. A typical clang/LLVM layout, used by both Rust and Emscripten, looks like this:

Linear memory layout A single ArrayBuffer. Low addresses hold the data and globals section, then the shadow stack which grows downward toward higher offsets is reserved, then the heap grows upward. memory.grow appends whole 64 KiB pages at the high end and may replace the backing buffer. data & globals (low addresses) shadow stack stack pointer grows down heap (malloc / free) break grows up pages appended by memory.grow → 0x0 high stack ↓ heap ↑

Concretely: the data section and globals sit at low addresses, a fixed-size shadow stack is reserved above them (the linker default is 1 MiB; its pointer decrements as frames are pushed), and the heap occupies everything above that, growing upward as the allocator hands out blocks. When the heap runs out of room, the allocator calls memory.grow to append more pages at the high end.

The word “stack” here is doing double duty, and the distinction matters. WebAssembly has a real operand stack inside the engine — the value stack that holds i32/f64 operands as instructions execute — and it is not part of linear memory at all; you cannot address it, take its pointer, or smash it. What the layout diagram calls the shadow stack is a software construct the compiler maintains in linear memory to hold things a real machine stack would: spilled locals, the address-of’d variables, large structs returned by value, and arrays whose addresses escape. So when a Rust function takes &mut buf to a local array, that array lives in the shadow stack region of linear memory, and a deep-enough recursion overflows it — producing a trap from a guard-page check or, worse on some configs, silent corruption of the data section below. The size of that shadow stack is fixed at link time (-z stack-size=N for clang/wasm-ld), which is why a Wasm “stack overflow” cannot grow its way out the way a native thread can; you raise the limit at build time or not at all.

memory.grow and what it returns

memory.grow is a Wasm instruction (exposed in JavaScript as WebAssembly.Memory.prototype.grow). You ask for N additional pages; it returns the previous size in pages on success, or -1 (as an i32, so 0xFFFFFFFF) on failure — it does not trap. Failure happens when growth would exceed the declared maximum, or when the engine cannot obtain a contiguous region that large.

const before = instance.exports.memory.grow(4);     // request 4 more pages
if (before === -1) {
  throw new Error("memory.grow failed — at maximum or host OOM");
}
const newBytes = instance.exports.memory.buffer.byteLength;   // (before + 4) * 65536

The critical, non-obvious consequence: a successful memory.grow may detach the old ArrayBuffer and replace it with a new, larger one. Every typed-array view you built over the previous memory.buffer becomes detached and reads as zero-length. Pointers inside Wasm stay valid (they are just offsets), but JavaScript views do not. This single fact is responsible for a large fraction of Wasm interop bugs and is dissected in why memory.grow invalidates pointers.

Growth is also not guaranteed to be cheap or even possible. On a non-shared memory the engine may grow in place if the host happens to have adjacent virtual address space reserved — many engines reserve the full declared maximum up front precisely so growth is a no-copy bookkeeping change — but it is free to fall back to allocate-and-copy, which is O(current size) in memory bandwidth. This is the strongest argument for declaring a realistic maximum: it lets the engine reserve the address range once and turn every later memory.grow into a cheap commit rather than a relocating copy. A memory with no maximum forces the engine to guess, and on 32-bit-host browsers it may refuse to reserve aggressively, making large growth more likely to return -1.

Step-by-step workflow

The standard pattern for managing module memory from JavaScript is: export an allocator, allocate from JS, write bytes, call the worker function, read the result, then free. Here is the full cycle.

  1. Declare a memory with a maximum so growth is bounded. In a hand-written WAT module:

    (memory (export "memory") 2 64)   ;; start 128 KiB, cap at 4 MiB
  2. Compile with an allocator and export alloc/dealloc. For Rust targeting bare wasm32-unknown-unknown, mark them #[no_mangle] so they appear as plain exports:

    cargo build --release --target wasm32-unknown-unknown
  3. Inspect the memory section to confirm the limits the binary actually declares:

    wasm-objdump -x target/wasm32-unknown-unknown/release/heap.wasm | grep -A2 "Memory"
  4. Allocate from JavaScript by calling the exported allocator, which returns a pointer (byte offset):

    const ptr = instance.exports.alloc(data.length);
  5. Build a fresh view and copy your bytes in — always construct the view after the alloc call, in case the allocator grew memory:

    new Uint8Array(instance.exports.memory.buffer, ptr, data.length).set(data);
  6. Call the function that consumes the buffer, passing (ptr, len).

  7. Read results, then free the block in a finally so a thrown error never leaks it:

    try { /* use the data */ } finally { instance.exports.dealloc(ptr, data.length); }

A concrete Rust + JS example

This Rust crate exposes a global-allocator-backed alloc/dealloc pair plus a function that sums a byte buffer. It compiles to bare wasm32-unknown-unknown with no wasm-bindgen, so the ABI is fully explicit.

use std::alloc::{alloc as rust_alloc, dealloc as rust_dealloc, Layout};

/// Allocate `len` bytes, return a pointer (byte offset into linear memory).
#[no_mangle]
pub extern "C" fn alloc(len: usize) -> *mut u8 {
    if len == 0 {
        return std::ptr::null_mut();
    }
    let layout = Layout::from_size_align(len, 1).unwrap();
    unsafe { rust_alloc(layout) }
}

/// Free a block previously returned by `alloc`. Caller must pass the same `len`.
#[no_mangle]
pub extern "C" fn dealloc(ptr: *mut u8, len: usize) {
    if ptr.is_null() || len == 0 {
        return;
    }
    let layout = Layout::from_size_align(len, 1).unwrap();
    unsafe { rust_dealloc(ptr, layout) }
}

/// Sum the bytes in [ptr, ptr+len). Pure computation over linear memory.
#[no_mangle]
pub extern "C" fn sum_bytes(ptr: *const u8, len: usize) -> u32 {
    let slice = unsafe { std::slice::from_raw_parts(ptr, len) };
    slice.iter().map(|&b| b as u32).sum()
}

The default global allocator here is dlmalloc, which Rust’s std uses on wasm32-unknown-unknown, so alloc/dealloc get real free-list bookkeeping for free. The JavaScript driver owns the allocation lifetime:

const { instance } = await WebAssembly.instantiateStreaming(fetch("/heap.wasm"));
const exports = instance.exports;

function sum(bytes) {
  const ptr = exports.alloc(bytes.length);
  if (ptr === 0) throw new Error("alloc returned null");
  try {
    // Build the view AFTER alloc — alloc may have grown memory and replaced the buffer.
    new Uint8Array(exports.memory.buffer, ptr, bytes.length).set(bytes);
    return exports.sum_bytes(ptr, bytes.length);
  } finally {
    exports.dealloc(ptr, bytes.length);            // ownership returns to the module
  }
}

console.log(sum(new TextEncoder().encode("WebAssembly"))); // 1103

Ownership across the boundary is the rule to internalize: whoever calls alloc is responsible for the matching dealloc. JavaScript borrowed the pointer; the module still owns the allocator’s metadata. If you hand a pointer out of the module (a function returning a (ptr, len) pair), document who frees it — this is exactly the contract passing complex types across the boundary formalizes for strings and structs.

Notice that alloc and sum_bytes are completely separate calls with no shared transactional context. Between them, the pointer is a bare integer held only by JavaScript — nothing in the module knows the allocation is “in use.” That is by design and it is what makes the model composable, but it also means the only thing keeping that block alive is your discipline in holding the pointer and eventually passing it to dealloc. There is no finalizer, no scope guard, no RAII across the boundary. Drop the pointer on the floor in JavaScript — overwrite the variable, let an exception skip the free — and the block is leaked: the allocator still considers it live, but no one can ever reference or free it again. This is why the try/finally is not stylistic; it is the boundary’s substitute for a destructor. Teams that ship hand-written ABIs reliably end up wrapping every alloc in a small helper that pairs it with its dealloc the way wasm-bindgen does internally, because doing it ad hoc at each call site eventually misses one.

Allocators and their tradeoffs

The allocator is a policy decision with a direct size-and-speed cost. Three options dominate Wasm builds.

  • dlmalloc — the default in Rust std and Emscripten. A mature, general-purpose allocator with free lists and coalescing. It handles real free, resists fragmentation reasonably, and is fast, but it adds roughly 6–10 KiB of code to your module and carries internal metadata per block.
  • wee_alloc — a tiny allocator (~1 KiB) designed for size-constrained Wasm. It was the go-to for shaving bytes, but it is effectively unmaintained/deprecated, leaks under some free patterns, and is no longer recommended for new projects. Reach for dlmalloc or a custom bump allocator instead.
  • Bump allocator — a next pointer that only ever increments; allocation is two or three instructions and there is no per-block metadata. It cannot free individual objects, only reset the whole arena, but for request-scoped or frame-scoped work it is the smallest and fastest option. Building one is the subject of implementing a bump allocator in Wasm.
Allocator Code size Individual free Fragmentation Use when
dlmalloc ~6–10 KiB yes low–moderate general heaps, long-lived objects
wee_alloc ~1 KiB yes (buggy) high legacy only — avoid
bump ~0.1 KiB no (reset only) none arena / per-frame work

The cost of growth

Allowing the heap to grow has its own price. In Emscripten, -s ALLOW_MEMORY_GROWTH=1 inserts a check and a buffer-reacquisition path around every memory access path that touches the heap, and it disables some size assumptions, costing a small amount of speed and code. The alternative — a fixed INITIAL_MEMORY large enough to never grow — wastes resident memory for workloads that rarely hit the ceiling. The right default for most apps is to set a generous initial size and allow bounded growth, then measure memory.buffer.byteLength under load.

Fragmentation is the other long-running cost, and it is the reason allocator choice is not purely a size decision. Because linear memory only ever grows — there is no way to return pages to the host mid-session, since memory.shrink does not exist in the core spec — every allocator’s high-water mark is permanent for the life of the instance. dlmalloc fights this by coalescing adjacent freed blocks and reusing holes, so a workload that allocates and frees in roughly LIFO order keeps its footprint flat. But a workload that interleaves long-lived and short-lived allocations of varying sizes leaves un-coalescable gaps: the freed bytes are real, but they are scattered in pieces too small for the next large request, so the allocator grows memory anyway and byteLength ratchets up even though “free” memory exists. The practical mitigation is to segregate lifetimes — put per-frame or per-request scratch in a bump allocator arena you reset wholesale, and keep only genuinely long-lived objects on the dlmalloc heap — so the general-purpose allocator never sees the churn that fragments it.

Gotchas & failure modes

  • memory.grow invalidates every JS view. After any call that might allocate (and therefore might grow), the old Uint8Array/Float32Array over memory.buffer is detached and reads zero-length. The symptom is silently reading all zeros or a TypeError: Cannot perform Construct on a detached ArrayBuffer. Re-create views from instance.exports.memory.buffer after every boundary call.
  • OOM trap. If the allocator calls memory.grow, gets -1, and the code does not handle it, dlmalloc returns a null pointer; dereferencing it loads from offset 0 and corrupts your data section, or an unreachable fires and you get RuntimeError: unreachable executed. Always null-check allocator returns on both sides.
  • Double free. Calling dealloc(ptr, len) twice — common when both a finally and an error path free the same pointer — corrupts dlmalloc’s free list and produces nondeterministic crashes later, far from the bug. Null out your pointer variable after freeing.
  • Wrong len to dealloc. With a manual ABI, dealloc needs the same size alloc was called with. Passing a different length corrupts the heap. Track the length alongside every pointer.
  • Leaks. Forgetting to free is invisible — the page never crashes, it just grows. Watch for memory.buffer.byteLength climbing across identical operations.

Verification

Confirm the declared memory limits straight from the binary:

wasm-objdump -x heap.wasm | grep -A3 "Memory\["
# Memory[1]:
#  - memory[0] pages: initial=2 max=64

Confirm your allocator exports are present and have the signatures you expect:

wasm-objdump -x heap.wasm | grep -E "func\[.*\] <(alloc|dealloc|sum_bytes)>"

At runtime, treat memory.buffer.byteLength as the ground truth for resident heap and watch it across a repeated operation to catch leaks and unexpected growth:

const start = instance.exports.memory.buffer.byteLength;
for (let i = 0; i < 1000; i++) sum(payload);          // a leaking sum() would grow this
console.log("delta bytes:", instance.exports.memory.buffer.byteLength - start);

A healthy alloc/free cycle keeps that delta at 0 (or one growth step that then plateaus). A steadily rising number is a leak.

The Memory64 proposal

Today linear memory is addressed with 32-bit offsets, capping a single memory at 4 GiB (and most engines at ~2–4 GiB in practice). The Memory64 proposal adds i64 addressing: a memory declared with the i64 index type uses 64-bit pointers, lifting the ceiling for in-browser databases, large WASM-based data tools, and scientific workloads. The cost is that pointers double in width, increasing memory traffic and, on 32-bit-favouring engines, slowing address arithmetic. It is shipping behind flags in major engines; for most apps the 32-bit address space is still the right default.

In this guide

Frequently Asked Questions

How big is a WebAssembly page? Exactly 64 KiB — 65,536 bytes. Memory sizes, the initial/maximum declarations, and memory.grow are all measured in whole pages, never bytes. A memory of initial=2 is a 128 KiB ArrayBuffer at startup.

Does Wasm have a garbage collector for linear memory? No. The core linear memory is unmanaged bytes; you allocate and free it explicitly through an allocator like dlmalloc. The separate WasmGC proposal adds managed reference types for compiling languages like Java or Kotlin, but those objects live outside linear memory and are not addressable as byte offsets.

Should I still use wee_alloc to shrink my module? No. wee_alloc is effectively unmaintained and has known leak bugs. If you need a smaller allocator than dlmalloc, write a bump allocator for arena-style work, or keep dlmalloc and spend the few kilobytes — wasm-opt will recover more size elsewhere.

Why does my pointer read garbage after calling a Wasm function? Most likely that function grew memory, detaching the ArrayBuffer your view aliased. Re-create the view from instance.exports.memory.buffer after the call. The full explanation is in why memory.grow invalidates pointers.

Who is responsible for freeing memory passed across the boundary? Whoever allocated it. If JavaScript called alloc, JavaScript must call the matching dealloc. If the module returns a freshly allocated buffer, document whether the caller or a later module call frees it — ambiguous ownership is the usual root cause of Wasm heap leaks.

← Back to JS/Wasm Interop & Memory Management