WebAssembly Linear Memory Growth in the Browser
WebAssembly memory has one property that makes it unlike everything else on this site: it is a ratchet. Every page it acquires it keeps until the instance is discarded. This page belongs to typed arrays, buffers and external memory inside JavaScript Memory Fundamentals & Runtime Mechanics.
| Symptom | Root Cause | Immediate Action |
|---|---|---|
| Memory stays at the peak after a big job | Linear memory cannot shrink | Discard and re-instantiate the module |
TypeError on a typed array after a call |
Growth detached the previous memory.buffer |
Re-create views after any call that may grow |
| Tab becomes unresponsive on a large file | Memory grew until the whole page was under pressure | Declare a maximum and chunk the input |
| Memory grows slowly across many small jobs | The module’s allocator never returns pages | Recycle the instance on a job or size threshold |
| Page count differs between browsers | Different allocator behaviour inside the module | Measure page count, not process memory |
Root Cause: A Ratchet, Not a Heap
A WebAssembly.Memory is a contiguous region addressed from zero, sized in 64 KB pages. The module’s own allocator — malloc compiled to Wasm, or whatever the source language provides — carves that region up. When it runs out, it executes memory.grow, the browser commits more pages, and the region gets larger.
There is no inverse. The specification provides memory.grow and nothing that returns pages. Freeing inside the module marks the space reusable by the module, which is genuinely useful — a second job of the same size reuses the pages rather than growing again — but the browser’s commitment is permanent for the instance’s lifetime.
So peak input size becomes steady-state memory. Process one 400 MB document and the instance holds roughly 400 MB of pages for as long as the page lives, whatever it does afterwards. On a desktop that is an annoyance; on a phone, where the whole tab budget may be 256 MB, it is the end of the session.
Growth also detaches views. memory.buffer returns an ArrayBuffer over the current region; growing replaces it, and every typed array built over the old one becomes detached — byteLength zero, reads throwing. Code that caches a Uint8Array at start-up and uses it after a growing call is the single most common WebAssembly integration bug.
Step-by-Step Fix
- Measure in pages, not megabytes. Command:
instance.exports.memory.buffer.byteLength / 65536. Expected: a page count you can log at job boundaries and chart. - Declare a maximum. Command:
new WebAssembly.Memory({ initial: 256, maximum: 4096 }). Verification: a runaway allocation throws inside the module instead of pressurising the tab. - Chunk oversized inputs. Verification: peak page count becomes a function of chunk size, flat across input sizes.
- Recycle after large jobs. Drop the instance and instantiate a fresh one when the page count exceeds a threshold. Verification: the page count returns to its initial value.
- Re-create views after every growing call. Verification: no
TypeErroron detached buffers under a stress test that forces repeated growth.
Command & Code Reference
Instantiating with an explicit maximum and a helper that always reads the current buffer.
// initial and maximum are in 64 KB pages: 256 pages = 16 MB, 4096 = 256 MB.
const memory = new WebAssembly.Memory({ initial: 256, maximum: 4096 });
const { instance } = await WebAssembly.instantiateStreaming(
fetch('/codec.wasm'),
{ env: { memory } }
);
// NEVER cache this — growth replaces the underlying ArrayBuffer and detaches
// every view built over the old one.
const view = () => new Uint8Array(memory.buffer);
function writeInput(bytes, ptr) {
view().set(bytes, ptr); // fresh view, current buffer, every time
}
A recycling wrapper that trades a few milliseconds of instantiation for a bounded steady state.
const MAX_PAGES_BEFORE_RECYCLE = 1024; // 64 MB
function createCodec(wasmUrl) {
let instancePromise = instantiate(wasmUrl);
async function instantiate(url) {
const memory = new WebAssembly.Memory({ initial: 256, maximum: 4096 });
const { instance } = await WebAssembly.instantiateStreaming(
fetch(url), { env: { memory } });
return { instance, memory };
}
return {
async run(input) {
const { instance, memory } = await instancePromise;
const result = instance.exports.process(input);
const pages = memory.buffer.byteLength / 65536;
if (pages > MAX_PAGES_BEFORE_RECYCLE) {
// Dropping the instance is the only way to give the pages back.
instancePromise = instantiate(wasmUrl);
}
return result;
},
};
}
Chunking, so one large input never sets the high-water mark in the first place.
const CHUNK = 4 * 1024 * 1024; // 4 MB — peak memory tracks this, not the file
async function processLargeFile(codec, file) {
const results = [];
for (let offset = 0; offset < file.size; offset += CHUNK) {
const slice = file.slice(offset, offset + CHUNK);
const bytes = new Uint8Array(await slice.arrayBuffer());
results.push(await codec.run(bytes));
// `bytes` is collectable here; the module reuses its own pages next round.
}
return results;
}
Verification & Regression Prevention
Verify by logging the page count at every job boundary rather than by watching the browser’s memory readout, which mixes in everything else on the page. The sequence to look for is: baseline, peak during the job, and back to baseline after recycling. A page count that never returns is the whole bug in one number.
For prevention, add an assertion to the module’s integration test: run a large job, then a small one, and assert the page count after the small job is within a page or two of the starting value. That fails immediately if someone removes the recycling logic or raises the threshold past the point where it does anything. Pair it with a stress test that forces repeated growth and asserts no detached-buffer errors, which covers the cached-view bug at the same time.
FAQ
Can WebAssembly memory be shrunk?
No. The memory.grow instruction adds pages and there is no corresponding shrink. Freeing memory inside the module returns it to the module’s own allocator for reuse, but the linear memory the browser has committed stays committed for the lifetime of the instance. Discarding the instance is the only way to give the pages back.
Does a detached ArrayBuffer after growth cause bugs?
It causes a very common one. Growing the memory detaches any existing view over memory.buffer, so a cached Uint8Array taken before the growth throws or reads zeroes afterwards. Re-create views from memory.buffer after every call that might grow, or wrap access in a helper that always reads the current buffer.
Should I set a maximum on WebAssembly.Memory?
Yes for anything long-lived. Without a maximum, a runaway allocation grows until the tab is under memory pressure and the whole page suffers. With one, the growth fails inside the module where you can catch it and report a useful error to the user.
Related
- Typed Arrays, Buffers and External Memory — the wider picture of memory the heap does not count
- ArrayBuffer and Blob Memory Outside the JS Heap — the JavaScript-side buffers that feed a module
- Remote Debugging Memory on Mobile Browsers — measuring this on the devices where it actually hurts