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 thewasm32-unknown-unknowntarget (rustup target add wasm32-unknown-unknown) - [ ]
wasm-pack 0.12+andwabt(wasm-objdump,wasm-validate) on yourPATH - [ ] A browser or
node 20+to instantiate modules and readinstance.exports.memory.buffer - [ ] Familiarity with the stack-based VM execution model and where the value stack ends and
linear memorybegins - [ ] Comfort reading typed-array views over an
ArrayBufferfrom 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:
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.
-
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 -
Compile with an allocator and export
alloc/dealloc. For Rust targeting barewasm32-unknown-unknown, mark them#[no_mangle]so they appear as plain exports:cargo build --release --target wasm32-unknown-unknown -
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" -
Allocate from JavaScript by calling the exported allocator, which returns a pointer (byte offset):
const ptr = instance.exports.alloc(data.length); -
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); -
Call the function that consumes the buffer, passing
(ptr, len). -
Read results, then free the block in a
finallyso 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 Ruststdand Emscripten. A mature, general-purpose allocator with free lists and coalescing. It handles realfree, 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 fordlmallocor a custom bump allocator instead.- Bump allocator — a
nextpointer 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.growinvalidates every JS view. After any call that might allocate (and therefore might grow), the oldUint8Array/Float32Arrayovermemory.bufferis detached and reads zero-length. The symptom is silently reading all zeros or aTypeError: Cannot perform Construct on a detached ArrayBuffer. Re-create views frominstance.exports.memory.bufferafter every boundary call.- OOM
trap. If the allocator callsmemory.grow, gets-1, and the code does not handle it,dlmallocreturns a null pointer; dereferencing it loads from offset 0 and corrupts your data section, or anunreachablefires and you getRuntimeError: unreachable executed. Always null-check allocator returns on both sides. - Double free. Calling
dealloc(ptr, len)twice — common when both afinallyand an error path free the same pointer — corruptsdlmalloc’s free list and produces nondeterministic crashes later, far from the bug. Null out your pointer variable after freeing. - Wrong
lentodealloc. With a manual ABI,deallocneeds the same sizeallocwas 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.byteLengthclimbing 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
- Implementing a bump allocator in Wasm —
build a minimal
next-pointer allocator with alignment rounding and arena reset. - Why memory.grow invalidates pointers — why JS typed-array views detach when the backing buffer is replaced, and how to re-create them.
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.
Related
- JS/Wasm Interop & Memory Management — the boundary contract this heap management sits under.
- Zero-copy data transfer patterns — aliasing
linear memorywith views instead of copying. - Understanding Wasm linear memory limits — the 4 GiB ceiling and page accounting.
- Implementing a bump allocator in Wasm — the smallest possible allocator.
- Why memory.grow invalidates pointers — detached views and the fix.
← Back to JS/Wasm Interop & Memory Management