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 WebAssembly global
  • [ ] wabt 1.0.34+ for wat2wasm if 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 modellinear memory is the single buffer described there

How memory sizing works, step by step

  1. Think in pages, not bytes. WebAssembly memory is allocated in fixed page units of 64 KiB (65,536 bytes) — never bytes directly. A module declaring 16 pages reserves exactly 16 × 65536 = 1,048,576 bytes (1 MiB). Every limit in the spec is expressed in pages.

  2. Set initial and maximum at construction. initial pages are reserved and zero-filled at instantiation; maximum is 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
  3. Grow at runtime with memory.grow(deltaPages). It appends deltaPages pages and returns the previous page count on success. The backing ArrayBuffer may be replaced, so re-read memory.buffer afterward:

    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)
  4. Grow from inside Wasm with memory.grow. The instruction mirrors the JS API: it pops a page delta and pushes the previous size, or -1 on failure:

    (func (export "add_page") (result i32)
      (memory.grow (i32.const 1)))    ;; returns old page count, or -1 if it can't grow
  5. 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^32 bytes). 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.

  6. Lift the ceiling with Memory64. The memory64 proposal makes the index type i64, raising the architectural limit far past 4 GiB. Declare it in the text format with the i64 index type:

    (module
      (memory (export "mem") i64 1 100000))   ;; 64-bit memory: initial 1 page, max 100000

    Compile with clang --target=wasm64 / -mwasm64, or in Rust with RUSTFLAGS="-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 at grow, when initial (or a too-large maximum reservation) cannot be satisfied. Lower initial, or move large allocations to runtime grow calls so they fail recoverably instead.

  • grow returns -1 instead of throwing — the JS WebAssembly.Memory.prototype.grow and the memory.grow instruction 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 exceeds maximum (or the 4 GiB cap) as a thrown RangeError rather 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 growgrow may allocate a fresh ArrayBuffer and detach the old one. Every Uint8Array/DataView over the previous memory.buffer becomes zero-length. Re-create views from memory.buffer after 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.

← Back to Stack vs Heap Execution Model