Sizing Initial and Maximum Memory
This page answers one question: a WebAssembly memory has an initial size and an optional maximum, both in 64 KiB pages — how do you choose them, and what goes wrong when they are too small or too large?
Prerequisites
- [ ] A module and a realistic workload to run it with.
- [ ] Access to the link or build settings that control memory (
wasm-ldflags, Emscripten settings, or Rust link args). - [ ] A way to observe
memory.buffer.byteLengthduring a run.
What the two numbers mean
Every WebAssembly memory is declared with a minimum size — the initial number of 64 KiB pages it has when created — and optionally a
maximum. The module can grow memory at runtime with memory.grow, one or more pages at a time, up to the maximum; growth beyond it fails and
returns -1. Memory never shrinks.
The two numbers trade off different costs. Initial memory is committed when the instance is created, so a large initial size costs memory immediately, even if the program never uses it. A small initial size costs time later, because the program grows memory as it allocates, and each growth may reallocate or remap the buffer and invalidates every JavaScript view into it, as explained in why memory.grow invalidates pointers. The maximum is a ceiling: too low and legitimate work fails with out-of-memory; absent or too high and a runaway allocation can consume the device’s memory until the browser kills the tab.
Step 1 — measure the real working set
Start from data, not guesses. Run the module through representative workloads — typical input, the largest input you intend to support — and record memory size over time:
const mem = instance.exports.memory;
let peak = mem.buffer.byteLength;
const sample = () => { peak = Math.max(peak, mem.buffer.byteLength); };
const timer = setInterval(sample, 50);
await runWorkload(instance, largestSupportedInput);
clearInterval(timer); sample();
console.log("start MB", (initialBytes / 2 ** 20).toFixed(1), "peak MB", (peak / 2 ** 20).toFixed(1));
For a more detailed view of how memory evolves — and whether it plateaus or keeps climbing, which would indicate a leak — use the approach in tracking linear memory growth over time.
Step 2 — set initial to cover startup
A good initial size covers static data, the shadow stack and the heap the program needs to get through startup and its first typical operation without growing. That makes startup predictable and avoids a burst of growth while the user waits. For many applications that is a few megabytes:
# C / C++ with clang
clang ... -Wl,--initial-memory=16777216 # 16 MiB (must be a multiple of 64 KiB)
# Emscripten
emcc ... -sINITIAL_MEMORY=16MB -sALLOW_MEMORY_GROWTH=1
# Rust (.cargo/config.toml)
[target.wasm32-unknown-unknown]
rustflags = ["-C", "link-arg=--initial-memory=16777216"]
Do not set initial to the peak of the largest workload unless nearly every user hits it. In a threaded build, or anywhere many instances run at once, initial memory multiplies; size it for the common case and let growth handle the rest.
Step 3 — set a maximum that matches what you support
Choose the maximum from the largest input you intend to handle plus headroom, and from what the target devices can afford. A document editor that supports files up to 100 MB might need a 512 MB maximum; a small parsing module might need 64 MB. Then make out-of-memory a handled condition rather than a crash, as in handling out-of-memory in Wasm:
clang ... -Wl,--max-memory=536870912 # 512 MiB
emcc ... -sMAXIMUM_MEMORY=512MB
Shared memories, used for threads, must declare a maximum, and engines may reserve address space for the whole maximum up front. On 32-bit devices and some phones, an excessive maximum on a shared memory can itself fail to allocate. Keep shared maximums no larger than you need.
Step 4 — grow in sensible steps
How memory grows matters as much as the bounds. Allocators that grow memory one page at a time cause many small grows; good allocators grow in larger steps, typically doubling or by a generous fixed chunk. If you manage growth yourself — a custom allocator, or JavaScript growing a memory it owns — grow geometrically:
function ensureCapacity(memory, neededBytes) {
const have = memory.buffer.byteLength;
if (neededBytes <= have) return;
const target = Math.max(neededBytes, have * 2); // at least double
memory.grow(Math.ceil((target - have) / 65536));
}
Each grow on a non-shared memory may move the underlying buffer, so recreate typed-array views afterwards, and avoid holding views across calls that might allocate.
Step 5 — respect platform ceilings
Several ceilings apply regardless of your settings. A 32-bit WebAssembly memory can never exceed 4 GiB, and browsers historically allowed less — 2 GiB in older releases, 4 GiB in current desktop Chrome and Firefox. Mobile browsers may refuse large allocations well below that, depending on device memory, and an operating system may kill a tab that commits too much even if the engine allowed it. If you need more than 4 GiB, the memory64 proposal raises the ceiling, as described in Memory64 and large heaps.
How virtual reservation changes the picture
On 64-bit desktop systems, engines usually reserve a large virtual address range for each 32-bit memory — often the full 4 GiB plus guard regions — and commit physical pages only as memory grows. That is why growth there is cheap and rarely moves the buffer, and why bounds checks can be done by hardware page protection rather than explicit comparisons. On platforms where virtual address space is scarce — 32-bit devices, some mobile configurations, many simultaneous instances — engines cannot reserve that much and fall back to copying on growth and explicit bounds checks. The practical effect is that the same module can grow cheaply on a desktop and expensively on a phone, which is one more reason to size initial memory for the common case and to test growth-heavy workloads on real mobile hardware.
Read the plateau in that timeline carefully. Memory rose when the large file was opened and again during export, and then stayed at its high point even after the export finished. That is normal: the allocator reuses freed pages internally, but the WebAssembly memory itself never shrinks. What matters is that it plateaus — repeating the same operations should not keep raising it. A steady climb across repeated identical operations is a leak, not a sizing problem, and raising the maximum would only delay the failure.
For multi-instance hosts — a page running several workers, a server running many tenants — think of memory in totals. Each instance’s peak counts separately, and the sum, not any one module’s maximum, is what the device or server must provide. Sizing per instance for the common case, with growth for the rare large job, keeps that total close to what work actually needs.
Expected output
After measuring and setting the sizes, a typical session starts with no growth and peaks well under the maximum:
start MB 32.0 peak MB 71.5 max MB 512 grows: 2
Gotchas
- Initial size not a multiple of 64 KiB. The linker rejects it. Use multiples of 65,536 bytes.
- No maximum on a shared memory. Instantiation fails; shared memories require one.
- Holding typed-array views across growth. They become detached. Recreate views after any call that may grow memory.
- Different sizes in different builds. A debug build with sanitizers needs far more memory than release. Set sizes per configuration.
- Sizing for desktop only. Phones have far less memory to give. Test peak usage on a target device.
Performance note
For the document editor above, moving from a 1 MiB initial memory to 32 MiB removed 23 grows during startup and about 9 ms on a mid-range phone. Setting a 512 MiB maximum turned an occasional tab crash on enormous files into a handled “file too large” message, which users rated as far less frustrating than losing their work.
Frequently Asked Questions
Does a large initial memory slow down instantiation? Slightly, because the memory must be allocated and zeroed; on 64-bit systems with lazy commit the cost is small. It mostly costs memory, not time.
Can JavaScript grow the memory?
Yes — memory.grow(pages) works from JavaScript too. The module’s allocator must be aware of it if it manages the new space.
What happens at the maximum?
memory.grow returns -1; allocators then report failure, typically as a null pointer in C or an allocation error in Rust.
Do I need to set these for Rust and wasm-bindgen? Rust’s defaults work for many modules; set them explicitly when you have measured a need. wasm-bindgen does not change memory sizing.
Related
- Understanding Wasm linear memory limits — the engine-level ceilings.
- Setting stack size and memory limits at link time — the linker flags in context.
- Why Wasm memory never shrinks — the other half of the growth story.
- Monitoring Wasm memory in production — checking your numbers against real users.
← Back to Stack vs Heap Execution Model