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.

Growing memory from JavaScript and what must follow JavaScript calls memory.grow. The old ArrayBuffer is detached and a new larger one replaces it. JavaScript re-creates its typed-array views, then tells the module's allocator about the new pages so it can use them. JavaScript WebAssembly.Memory module allocator memory.grow(160) — +10 MB returns old size; old buffer detached re-create Uint8Array views exports.heap_add_region(oldEnd, bytes) new pages available for malloc

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.

Ways to make room in linear memory Letting the allocator grow memory on demand is automatic but can fail mid-job. Asking the module to reserve memory up front grows through the allocator and fails early. Growing directly from JavaScript adds pages the allocator does not know about unless it supports adding regions. grow on demand allocator calls memory.grow many small growths can fail mid-job default behaviour reserve via the module export reserve(bytes) allocator grows once, early fails before work starts best for big jobs grow from JavaScript memory.grow(pages) in JS allocator unaware of pages needs an add-region API only for host-managed data

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 from memory.buffer.
  • Growing from JavaScript and expecting malloc to 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.

Time until an impossible job fails on a phone Milliseconds until the user is told a 300 MB job cannot run on a device with limited memory, with an up-front reserve and with growth on demand during the job. ms until the out-of-memory error reserve before the job 0.8 ms grow on demand during the job 6,100 ms

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.

← Back to Linear Memory Management & Allocators