WebAssembly memory.grow Detaches Your Views: A Practical Fix
A successful memory.grow detaches the ArrayBuffer behind WebAssembly memory, silently zeroing cached views. Here is a view cache that survives growth.
10 Jun 2026, 14:35 UTC

The failure mode: a view that silently becomes empty
You cache a Uint8Array over memory.buffer once at startup, hand it to a decode loop, and everything works — until the module needs more room. After a memory.grow call, that cached view reports byteLength === 0. Nothing throws at the point of growth. The error surfaces later as a truncated read, an empty string from TextDecoder, or a bounds error somewhere unrelated.
The cause is specified behavior, not an engine bug: a successful grow detaches the ArrayBuffer that backed the memory. Every view created over the old buffer becomes zero-length. memory.buffer itself is fine — it now points at a new, larger buffer — but anything you cached is dead.
What memory.grow actually does
WebAssembly linear memory is measured in pages of 64 KiB. WebAssembly.Memory.prototype.grow(deltaPages) adds deltaPages pages and returns the previous page count. If the module declared a maximum, growth past that maximum fails; engines also enforce their own limits. On failure, grow returns -1 rather than throwing.
So the return value is a check, not a formality:
const prevPages = memory.grow(deltaPages);
if (prevPages === -1) {
// at the declared maximum, or the engine refused the allocation
throw new Error('memory.grow failed');
}
Two details matter for the fix. First, detachment is part of the JS embedding — standalone runtimes and non-browser hosts may expose memory differently, so verify on your actual target. Second, the wasm module can grow its own memory with the memory.grow instruction, and JS gets no callback when it does. A counter that only tracks your own JS calls will miss that case.
A view cache that survives growth
The pattern below keeps hot-path access fast by caching views, and rebuilds them when the buffer changes. It is a sketch — the shape is the point, not the exact names.
const PAGE = 65536;
function makeAccessor(memory) {
let lastBuffer = null;
let u8 = null, dv = null;
function views() {
// Rebuild if the buffer identity changed — covers both
// JS-driven grow and growth performed inside the module.
if (memory.buffer !== lastBuffer) {
lastBuffer = memory.buffer;
u8 = new Uint8Array(lastBuffer);
dv = new DataView(lastBuffer);
}
return { u8, dv };
}
function ensureCapacity(byteLength) {
const neededPages = Math.ceil(byteLength / PAGE);
const havePages = memory.buffer.byteLength / PAGE;
if (neededPages > havePages) {
const prev = memory.grow(neededPages - havePages);
if (prev === -1) throw new Error('memory.grow failed');
}
return views();
}
return { ensureCapacity, views };
}
Comparing memory.buffer against the last seen buffer is cheap and catches module-internal growth, which a generation counter bumped only by JS would not. If your module never grows memory on its own, a counter is equivalent and marginally cheaper.
Callers must re-acquire views after any operation that might grow. The rule is simple: ensureCapacity returns fresh views, and you never hold a view across a grow.
Pre-size or grow: pick your trade-off
Two strategies, and they fail in different ways.
- Pre-size at instantiation. Declare initial memory large enough for steady state. Views stay valid, there is no relocation or copy cost, and the code is simpler. The cost is resident footprint — a large initial size may reserve more address space and memory than you need. Whether unused pages are lazily committed depends on the engine; measure rather than assume.
- Grow on demand. Smaller baseline footprint, but you pay for growth and you must handle detachment everywhere. Growing in larger, less frequent steps amortizes the overhead but raises peak memory; growing by exactly what you need keeps peaks low but grows more often.
Note that memory.copy and memory.fill — the bulk memory operations — do not help here. They move bytes inside wasm memory quickly, but they do not prevent the JS-side buffer from detaching.
Checking it on your target
- Build a minimal module that exports its memory and a function that calls
memory.grow. - In the console, cache
new Uint8Array(memory.buffer), callgrow(1), and confirm the cached view'sbyteLengthis now0. That confirms the detachment behavior on your runtime. - Inspect the emitted module's memory limits with
wasm-objdump -xor an equivalent tool, and compare against your linker or compiler flags for initial and maximum memory. - Before publishing any footprint or performance claim, measure allocation and growth with the target runtime's memory tooling. Engines may remap pages instead of copying bytes, so growth cost is not something to assume.
The takeaway: treat memory.buffer as something that can be replaced, never as a stable reference. Re-acquire views after growth, check grow's return value, and choose pre-sizing or chunked growth based on which cost you would rather pay.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.