Understanding Wasm linear memory limits
This guide answers one concrete question: how big can a WebAssembly module’s linear memory get,
how do you grow it at runtime, and what fails when you hit a ceiling? You will configure initial
and maximum, grow memory in page units, read back the exact return values, and know which limit
(maximum, the 32-bit 4 GiB cap, or the host) you are bumping into.
Prerequisites
- [ ] Node.js 20+ or a browser with the
WebAssemblyglobal - [ ]
wabt1.0.34+ forwat2wasmif you assemble the example module - [ ] Optional: Chromium 133+ or Firefox 134+ for Memory64 testing (shipped on by default; older engines need
--enable-experimental-webassembly-features) - [ ] Comfort with the stack vs heap execution model —
linear memoryis the single buffer described there
How memory sizing works, step by step
-
Think in pages, not bytes. WebAssembly memory is allocated in fixed
pageunits of 64 KiB (65,536 bytes) — never bytes directly. A module declaring 16 pages reserves exactly16 × 65536 = 1,048,576bytes (1 MiB). Every limit in the spec is expressed in pages. -
Set
initialandmaximumat construction.initialpages are reserved and zero-filled at instantiation;maximumis the hard runtime ceiling. Both are page counts:// 1 MiB reserved now, allowed to grow up to 16 MiB const memory = new WebAssembly.Memory({ initial: 16, maximum: 256 }); console.log(memory.buffer.byteLength); // 1048576 -
Grow at runtime with
memory.grow(deltaPages). It appendsdeltaPagespages and returns the previous page count on success. The backingArrayBuffermay be replaced, so re-readmemory.bufferafterward:const prevPages = memory.grow(16); // add 16 pages (1 MiB) console.log(prevPages); // 16 (the size BEFORE growing) console.log(memory.buffer.byteLength); // 2097152 (now 2 MiB) -
Grow from inside Wasm with
memory.grow. The instruction mirrors the JS API: it pops a page delta and pushes the previous size, or-1on failure:(func (export "add_page") (result i32) (memory.grow (i32.const 1))) ;; returns old page count, or -1 if it can't grow -
Know the 32-bit ceiling. Default (“i32”) memories address bytes with a 32-bit pointer, so the absolute maximum is 65,536 pages = 4 GiB (
2^32bytes). In practice browsers cap a single memory lower — commonly around 2–4 GiB — because the buffer must be one contiguous allocation in the host’s virtual address space. -
Lift the ceiling with Memory64. The
memory64proposal makes the index typei64, raising the architectural limit far past 4 GiB. Declare it in the text format with thei64index type:(module (memory (export "mem") i64 1 100000)) ;; 64-bit memory: initial 1 page, max 100000Compile with
clang --target=wasm64/-mwasm64, or in Rust withRUSTFLAGS="-C target-feature=+memory64". Memory64 ships on by default in recent Chromium and Firefox; older engines need the experimental-features flag above.
Where the toolchain sets these limits
The page counts in the binary’s memory section come straight from compiler flags, so you rarely
write WebAssembly.Memory by hand for a real module — you set the bounds at build time:
| Toolchain | Flag | Effect |
|---|---|---|
| Emscripten | -sINITIAL_MEMORY=16777216 |
initial in bytes (256 pages here) |
| Emscripten | -sMAXIMUM_MEMORY=134217728 |
maximum in bytes (2048 pages) |
| Emscripten | -sALLOW_MEMORY_GROWTH=1 |
emits grow calls; without it max=init |
| LLVM/wasm-ld | --initial-memory=/--max-memory= |
direct memory section control (bytes) |
| Rust | -C link-args=--max-memory=N |
passed through to lld |
Note that Emscripten and wasm-ld take bytes and round up to whole pages, while the JS
WebAssembly.Memory constructor takes pages. Mixing the two units is a frequent source of
“my maximum is 65,536× too big” bugs.
Shared memory has stricter rules
A shared: true memory (the kind backed by a SharedArrayBuffer for threads) requires a
maximum, because the buffer cannot be reallocated and moved while other threads hold views into
it — growth must happen in place within a pre-reserved region:
// maximum is mandatory for shared memory
const shared = new WebAssembly.Memory({ initial: 16, maximum: 256, shared: true });
Omit maximum here and the constructor throws TypeError: WebAssembly.Memory(): maximum is required for shared memory. Shared memory also needs the page to be cross-origin isolated
(Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp);
without those headers the SharedArrayBuffer constructor is unavailable and instantiation fails
before any limit is reached.
Expected output / verification
Read the limits straight off the live objects so there is no guessing:
const memory = new WebAssembly.Memory({ initial: 1, maximum: 3 });
console.log(memory.buffer.byteLength); // 65536 (1 page)
console.log(memory.grow(1)); // 1 (old size = 1 page)
console.log(memory.buffer.byteLength); // 131072 (2 pages)
console.log(memory.grow(1)); // 2 (old size = 2 pages)
console.log(memory.grow(1)); // -1 (would exceed maximum of 3 -> rejected)
console.log(memory.buffer.byteLength); // 196608 (still 3 pages, unchanged by the failed grow)
Confirm the declared limits inside the binary itself:
wasm-objdump -x module.wasm | grep -A1 "Memory"
# Memory[1]:
# - memory[0] pages: initial=1 max=3
The key invariants: a successful grow returns the old page count and never throws; a failed grow
returns -1 and leaves buffer.byteLength exactly as it was.
Gotchas
-
RangeError: WebAssembly.Memory(): could not allocate memory— thrown at the constructor, not atgrow, wheninitial(or a too-largemaximumreservation) cannot be satisfied. Lowerinitial, or move large allocations to runtimegrowcalls so they fail recoverably instead. -
growreturns-1instead of throwing — the JSWebAssembly.Memory.prototype.growand thememory.growinstruction signal failure by return value, not an exception. Code that ignores the result will keep running against an unchanged buffer and corrupt data. Always check:if (memory.grow(n) === -1) { /* handle */ }. -
RangeError: WebAssembly.Memory.grow(): Maximum memory size exceeded— some engines surface a grow request that exceedsmaximum(or the 4 GiB cap) as a thrownRangeErrorrather than-1, depending on the path. Wrap growth that might cross a hard limit, and never assume one engine’s behavior matches another’s. -
Detached views after a successful grow —
growmay allocate a freshArrayBufferand detach the old one. EveryUint8Array/DataViewover the previousmemory.bufferbecomes zero-length. Re-create views frommemory.bufferafter any successful grow — see why memory.grow invalidates pointers.
Budgeting pages for a real workload
Picking initial and maximum is a sizing exercise, not a guess. Work backward from the largest
live data set the module holds at once, then add the fixed overhead the toolchain places below it.
A typical 32-bit Emscripten layout, from low to high addresses, is: data and globals, then the
shadow stack (default 64 KiB = 1 page), then the malloc heap that grows upward. So the floor is
roughly “your data size + stack reservation,” and everything above is heap.
Worked example for an image filter that must hold one 1920×1080 RGBA frame in and one out:
one RGBA frame = 1920 * 1080 * 4 bytes = 8,294,400 bytes ≈ 127 pages
two frames = 16,588,800 bytes ≈ 254 pages
data + shadow stack overhead ≈ 8 pages
allocator slack (fragmentation headroom) ≈ 16 pages
------------------------------------------------------------------
initial budget ≈ 278 pages (~18 MiB)
Round initial to a comfortable figure (say 288 pages) so the first frame never triggers a grow,
and set maximum to whatever the worst case plus a safety margin is — perhaps 512 pages (32 MiB)
if the app might process two frames at higher resolution. The point is to size initial to the
steady state and reserve maximum for spikes, so growth is rare and never silently fails.
Performance note
initial pages are reserved at instantiation, which directly affects cold-start cost: a large
initial forces the engine to zero-fill that region up front, while a generous maximum mostly
reserves virtual address space and is cheap. Prefer a small initial plus a high maximum and let
the module grow on demand — but batch growth (grow by many pages at once) rather than a page at a
time, because each grow may trigger a fresh contiguous allocation and a memcpy of existing bytes
into the new buffer, which is O(current size).
Frequently Asked Questions
How many bytes is one Wasm memory page?
Exactly 65,536 bytes (64 KiB). It is fixed by the specification and is the unit for initial,
maximum, and every memory.grow argument and return value.
What is the maximum size of WebAssembly linear memory?
A default 32-bit (i32) memory tops out at 65,536 pages = 4 GiB (2^32 bytes), though browsers often
cap a single contiguous memory somewhat lower. The memory64 proposal switches the index type to
i64 and raises the ceiling far beyond 4 GiB.
Why does memory.grow return -1 instead of throwing?
Because growth failure is an expected, recoverable condition — the design lets you test the result
and fall back without exception-handling overhead. A return of -1 means nothing grew and the
buffer is unchanged; only certain hard-limit violations surface as a thrown RangeError.
Related
- Linear memory management & allocators — how an allocator carves the heap out of these pages.
- Why memory.grow invalidates pointers — the detached-buffer trap in detail.
- WebAssembly text format (WAT) basics — declaring the
memorysection by hand.
← Back to Stack vs Heap Execution Model