Understanding WebAssembly Linear Memory Growth: Practical Trade‑offs for Deterministic Performance
WebAssembly's linear memory gives you explicit control over allocation, but growing that memory copies the whole buffer—know the costs before you hit host limits.
16 Oct 2026, 13:27 UTC

The problem: predictable latency vs. hidden copies
WebAssembly gives you a single, contiguous linear memory that you address with byte offsets. The memory is divided into 64 KiB pages, and the only way to get more space is the memory.grow instruction (exposed in C/C++ as __builtin_wasm_memory_grow or via the emscripten API). The catch: growing the memory copies the entire existing buffer to a new, larger region. If you’re building a real‑time audio engine, a game loop, or an embedded controller, that copy can introduce latency spikes you didn’t plan for.
How the page model works
- Page size: fixed at 64 KiB (65 536 bytes).
- Initial size: declared in the module’s
memorysection (e.g.,(memory 10)for 10 pages ≈ 640 KiB). - Maximum size: optional upper bound (e.g.,
(memory 10 100)caps at 100 pages ≈ 6.4 MiB). The host enforces this limit; exceeding it traps. - Growth semantics:
memory.grow nrequests n additional pages. The runtime allocates a new buffer of(current + n) * 64 KiB, copies the old contents, then swaps the pointer. The instruction returns the previous page count on success, or-1on failure.
Concrete example: watching a growth in action
Compile the following C snippet with the WASI SDK (clang 15+), then run it under wasmtime to see the copy cost.
// grow_demo.c
#include
#include
#include
int main() {
// Current pages (should be 10 if the module declares (memory 10))
uint32_t before = __builtin_wasm_memory_size(0);
printf("Initial pages: %u\n", before);
// Request 5 more pages (320 KiB)
uint32_t grew = __builtin_wasm_memory_grow(0, 5);
if (grew == (uint32_t)-1) {
puts("growth failed");
return 1;
}
uint32_t after = __builtin_wasm_memory_size(0);
printf("Pages after growth: %u (requested +5)\n", after);
return 0;
}
Build & run (Linux/macOS, user‑level permissions):
# 1. Install wasi-sdk (provides clang + sysroot)
# 2. Compile targeting wasm32-wasi with an explicit max of 20 pages
clang --target=wasm32-wasi -O2 \
-Wl,--initial-memory=10 --max-memory=20 \
-o grow_demo.wasm grow_demo.c
# 3. Execute with wasmtime, enabling the WASI preview1 adapter
wasmtime grow_demo.wasm
What to check:
- The program prints the initial page count (10) and the post‑growth count (15).
- If you change
--max-memory=12and request 5 pages, the growth returns-1and the process exits with an error – the host enforced the limit. - For a rough latency measurement, wrap the
memory_growcall in a high‑resolution timer (e.g.,clock_gettimevia WASI) and compare runs with differentnvalues.
Risk note: The copy is performed by the runtime, not your code. On a 6.4 MiB heap the copy moves ~6 MiB of data; on a 100 MiB heap it moves ~100 MiB. In latency‑sensitive loops, avoid growing inside the hot path.
Trade‑offs you can control
| Decision | Benefit | Cost / Limitation |
|---|---|---|
Set a low max in the module | Host can reject growth early, preventing OOM | May abort legitimate allocations if the estimate is wrong |
| Pre‑allocate the maximum you’ll ever need | Zero growth copies at runtime | Wastes address space; some hosts (browsers) count reserved pages against quota |
| Grow in large chunks (e.g., 16‑page steps) | Fewer copy operations overall | Each step still copies the whole buffer; large steps increase peak memory pressure |
| Use a custom allocator that sub‑allocates from a fixed pool | Eliminates memory.grow entirely | Requires manual lifetime tracking; bugs become use‑after‑free or leaks |
Limitations & verification checklist
- Copy overhead is linear in current size. Profile with
wasmtime --profileorperfon the host to confirm the cost matches expectations. - Host limits differ. Browsers typically cap at 4 GiB (65536 pages) but may impose lower per‑origin quotas. Node’s
WebAssembly.Memorydefaults to a 2 GiB maximum unless you passmaximumin the descriptor. - No garbage collector. If you compile from Rust or C++, you own every allocation. Double‑free or dangling pointers manifest as memory corruption, not a managed exception.
- Portability. Code that assumes a fixed address for a global (e.g.,
static int *ptr = &global;) breaks if the module is instantiated with a different base address or if the memory is relocated during growth.
Practical verification step: after building, run wasm-objdump -x grow_demo.wasm | grep -A2 "memory" to confirm the declared initial and maximum page counts match your linker flags.
Actionable takeaway
Treat memory.grow as a rare, explicit operation, not a default allocation path. Decide at compile time (via --max-memory or the maximum field in the WebAssembly.Memory descriptor) how much address space you truly need, and allocate it up front when deterministic latency matters. If you must grow, do it in coarse steps, measure the copy time on your target host, and guard the call with a fallback (e.g., return an error to the caller) so a failed growth doesn’t silently corrupt state.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.