Growing Memory Safely from JavaScript
This page answers one task: JavaScript needs a WebAssembly module’s memory to be larger — to reserve space before a big job, or to make room for data it is about to write — and the growth must not break existing views, confuse the module’s allocator, or fail without warning.
Prerequisites
- [ ] Access to the module’s
WebAssembly.Memory, exported or imported. - [ ] Knowledge of the memory’s maximum, if it has one.
- [ ] Familiarity with typed-array views over memory, as in reading Wasm linear memory with typed arrays.
Two parties can grow the same memory
A module’s linear memory can be grown by two parties. The module grows it with the memory.grow instruction, usually from inside its allocator when the
heap runs out. JavaScript grows it with memory.grow(pages) on the WebAssembly.Memory object. Both operate on the same memory, and both have the
same effect: the memory becomes larger by a whole number of 64 KiB pages, and — for non-shared memory — the old ArrayBuffer is detached and replaced
by a new, larger one.
Growing from JavaScript is legitimate and sometimes the best option, but it has two traps. First, every typed-array view created before the growth is
now looking at a detached buffer, with length zero; reads return undefined and writes do nothing. Second, the module’s allocator does not know the
memory grew. It will not use the new pages until its own logic decides to grow — at which point it grows again, leaving JavaScript’s pages unused.
Safe growth from JavaScript means handling both.
Step 1 — grow with error handling
memory.grow(delta) returns the previous size in pages, and throws a RangeError if the growth would exceed the maximum or the engine cannot provide
the memory:
function growBy(memory, bytes) {
const pages = Math.ceil(bytes / 65536);
try {
const oldPages = memory.grow(pages);
return { start: oldPages * 65536, bytes: pages * 65536 };
} catch (err) {
if (err instanceof RangeError) return null; // cannot grow: report out-of-memory upstream
throw err;
}
}
Note the difference from the instruction: inside Wasm, memory.grow returns -1 on failure; the JavaScript method throws. Treat a null result as
an out-of-memory condition and refuse the job cleanly rather than proceeding, as in
handling out-of-memory in Wasm.
Step 2 — refresh every view
After any growth — by JavaScript or by the module — re-create views from memory.buffer. The robust pattern is never to cache a view across a call that
might grow memory, and to obtain views through a helper:
let u8 = new Uint8Array(memory.buffer);
function bytes() {
if (u8.buffer !== memory.buffer) u8 = new Uint8Array(memory.buffer); // detached → refresh
return u8;
}
bytes().set(input, ptr); // always a live view
The identity check is cheap and catches growth from either side. Emscripten’s glue does the same internally with updateMemoryViews(), and
wasm-bindgen’s generated code checks byteLength === 0 before reusing its cached view. The full background is in
why memory.grow invalidates pointers.
Shared memories behave differently: a SharedArrayBuffer is never detached, so old views stay valid but keep their old length. Views must still be
re-created to reach the new pages.
Step 3 — hand the new pages to the allocator
If JavaScript grows memory so that the module can allocate more, the module’s allocator must learn about it. There are two clean approaches.
The simplest is to not grow from JavaScript at all, and instead ask the module to reserve memory: export a function that allocates (and immediately frees) a block of the required size. The allocator grows memory itself, through its own logic, and the pages are then on its free list:
#[wasm_bindgen]
pub fn reserve(bytes: usize) -> bool {
let mut v: Vec<u8> = Vec::new();
v.try_reserve_exact(bytes).is_ok() // grows memory if needed, then drops the block
}
The alternative, for allocators that support it, is to add a region explicitly. Allocators designed for Wasm, such as talc, accept new memory spans;
dlmalloc does not. Unless the allocator documents such an API, prefer reserve.
Step 4 — grow before a large job, not during it
The point of growing from JavaScript is usually predictability: a big job will need, say, 300 MB, and it is better to find out before starting that the memory is not available, and to pay the growth cost up front rather than in many small steps mid-job.
async function runBigJob(file) {
const needed = estimateMemory(file.size); // from the format's known ratios
if (!wasm.reserve(needed)) throw new OutOfMemoryError(`needs ${needed >> 20} MB`);
return wasm.process(await file.arrayBuffer());
}
This also reduces the number of detach-and-replace events during the job, which matters when JavaScript holds views across several calls.
Step 5 — use JavaScript growth for host-managed regions
There is one case where growing from JavaScript is the natural choice: when JavaScript, not the module, manages a region of memory. Some designs reserve the top of linear memory for host-written data — a large input buffer the module reads but never allocates. JavaScript grows memory, writes the data into the new pages, and passes the address and length to the module. As long as the allocator never extends into that region — because the region is above everything the allocator owns and the allocator would grow beyond it — this is safe. It is fragile when the allocator later grows memory and starts placing objects after the host region, so document the layout and assert it in debug builds.
Imported memories and the initial size
Many problems with growth disappear when the memory starts large enough. If the host creates the memory and passes it in as an import — with
--import-memory at link time — JavaScript chooses the initial and maximum sizes:
const memory = new WebAssembly.Memory({ initial: 256, maximum: 16384 }); // 16 MB initial, 1 GB max
const { instance } = await WebAssembly.instantiateStreaming(fetch("app.wasm"), { env: { memory } });
A generous initial size costs little: browsers reserve address space for the maximum but commit physical pages lazily, as they are touched. Starting at the steady-state working size avoids a burst of growth events during startup, each of which detaches buffers and copies nothing but invalidates every view. The linking side is covered in importing memory from the host with --import-memory. For shared memory, the maximum is mandatory, and choosing it well is the main decision.
Expected output
Before a 300 MB job, reserve succeeds and memory grows once from 64 MB to 384 MB; the job runs with no further growth, and no JavaScript code reads from
a detached buffer. On a device that cannot provide the memory, the job fails immediately with an out-of-memory error instead of trapping halfway through.
Gotchas
- Stale views after growth. Reads return
undefined, writes are lost. Refresh views frommemory.buffer. - Growing from JavaScript and expecting
mallocto use it. The allocator does not know. Reserve through the module. - Ignoring the thrown
RangeError. Growth fails on constrained devices. Catch it and report out-of-memory. - Growing in tiny steps. Each growth replaces the buffer. Grow once for the expected need.
- Assuming growth succeeded because it did on desktop. Phones refuse far earlier. Test the failure path on a real device.
- Shared memory views keeping old length. They are not detached, but cannot see new pages. Re-create them.
Performance note
Growing memory by 300 MB in one call took 0.3 ms in Chrome — the pages are reserved, not touched. Letting the allocator grow on demand during the same job produced 37 growth events and, with views refreshed after each, added about 4 ms in total. The larger benefit was failing early: on a constrained phone, the up-front reserve failed in under 1 ms instead of after 6 s of work.
Frequently Asked Questions
Does memory.grow copy the existing data?
Not visibly. The new buffer contains the old bytes; engines usually reserve address space so no copy is needed.
Can memory be grown beyond the maximum later? No. The maximum is fixed when the memory is created.
Is growth from JavaScript allowed for shared memory? Yes. Other threads see the new size, but must re-create views to access it.
How do I know the current size?
memory.buffer.byteLength, or the value returned by memory.grow(0) in pages.
Should the module or JavaScript own growth decisions? The module, almost always. Its allocator knows what is free and what is needed; JavaScript should ask it to reserve rather than grow behind its back.
Does growing memory affect performance of later code? No. Bounds checks use guard regions or the current size, and growth does not slow down memory accesses afterwards.
Related
- Why Wasm memory never shrinks — the other direction.
- Creating views into Wasm memory safely — view helpers in depth.
- Setting stack size and memory limits at link time — choosing initial and maximum sizes.
- Implementing a bump allocator in Wasm — an allocator that grows memory itself.
← Back to Linear Memory Management & Allocators