Why memory.grow Invalidates Pointers
This guide explains precisely why a JavaScript typed-array view over WebAssembly linear memory stops
working after memory.grow, demonstrates the silent-zero bug it causes, and shows the one-line fix:
re-create your views from instance.exports.memory.buffer after any call that might grow.
The subtlety that trips up almost everyone: pointers inside Wasm stay valid, but JavaScript views do
not. A Wasm pointer is an offset; growing memory does not move offset 100. But a Uint8Array is bound to
a specific ArrayBuffer object, and growth can replace that object entirely.
Prerequisites
- [ ] A runtime to instantiate a module (
node 20+or a browser) - [ ] A module that exports a growable
memoryand a function that callsmemory.grow - [ ] Familiarity with the linear memory model —
pagesize and howmemory.growworks - [ ] Knowing that typed arrays alias an
ArrayBufferrather than owning bytes
Why the view detaches
WebAssembly linear memory is backed by a single ArrayBuffer. When you write
new Uint8Array(memory.buffer), the view holds a reference to that specific ArrayBuffer object and a
byte range within it. It does not track “the module’s memory” abstractly — it tracks one buffer instance.
memory.grow(n) must produce a larger contiguous region. The engine is permitted to allocate a brand-new,
larger buffer, copy the old contents into it, and detach the old one. Detaching sets the old
ArrayBuffer’s byteLength to 0 and severs every view built over it. After that, the engine points
memory.buffer at the new buffer — but your existing Uint8Array still references the old, now
zero-length one.
So three things happen at once on a reallocating grow:
- A new, larger
ArrayBufferis created;instance.exports.memory.buffernow returns it. - The old
ArrayBufferis detached —byteLengthbecomes 0, reads return nothing. - Wasm pointers are unaffected: offset 100 in the new buffer holds the same logical byte it did before, because the contents were copied. Only JavaScript’s handle to the bytes is stale.
This is why the linear memory management overview insists you treat any allocating call as potentially memory-growing.
It helps to be precise about what an ArrayBuffer actually is in the JavaScript object model. A typed
array is a view: a lightweight object holding three things — a reference to a backing buffer, a byte
offset, and a length. It owns no bytes of its own. “Detaching” a buffer is a defined operation in the
ECMAScript specification that transfers ownership of the underlying memory away from the buffer object,
leaving it in a permanent zero-length, unusable state. Once detached, a buffer can never be reattached;
the spec models this as a one-way transition. WebAssembly’s Memory.prototype.grow is specified to detach
the existing buffer and produce a fresh one precisely so that JavaScript code cannot keep reading through a
view into memory the engine has since relocated. The detachment is not an implementation quirk you might
get away with ignoring on some engine — it is mandated behaviour for non-shared memory, so a correct
program must assume it on every engine, every time.
The reason the engine can relocate at all comes back to how growth is implemented. To present a single
contiguous ArrayBuffer to JavaScript, the new, larger memory must be one unbroken byte range. If the
engine reserved enough virtual address space at instantiation (common when you declare a maximum), it can
commit more pages in place and hand back a new buffer object that points at the same base — cheap, but
still a new object, so still a detach. If it did not reserve ahead, it must allocate a larger region
elsewhere and copy, which is both slower and unambiguously a relocation. Either way, from JavaScript’s
perspective the only safe assumption is that memory.buffer is a fresh object after any grow.
Demonstrating the bug
Here is the failure in full. We grab a view, call a function that grows memory, then try to read through the stale view.
const memory = new WebAssembly.Memory({ initial: 1, maximum: 100 });
const { instance } = await WebAssembly.instantiate(bytes, { env: { memory } });
// Build a view over the CURRENT buffer.
let view = new Uint8Array(memory.buffer);
view[0] = 42;
console.log(view.byteLength); // 65536 — one page
// Something grows memory (here, directly; in real code it's an alloc inside a Wasm call).
memory.grow(1); // returns 1 (previous page count)
// The OLD view is now detached.
console.log(view.byteLength); // 0 <-- detached!
console.log(view[0]); // undefined <-- silent, no exception on read
The dangerous part is step three: reading view[0] after detachment returns undefined rather than
throwing, so the bug surfaces far downstream as “my data is all zeros” instead of a clean error. Writing
through it does throw:
view[0] = 7;
// TypeError: Cannot perform '%TypedArray%.prototype.set' on a detached ArrayBuffer
Expected output showing the detached view
Running the snippet above prints, in order:
65536
0
undefined
The 65536 → 0 transition on byteLength across a single memory.grow call is the unmistakable
signature of detachment. Any time a view’s byteLength drops to 0 without you slicing it, a grow happened
underneath you.
The fix: re-create views after any call that may grow
Never cache a typed-array view across a boundary call that could allocate. Re-read
instance.exports.memory.buffer and rebuild the view each time:
function withView(instance) {
// Always derive a fresh view from the live buffer.
return new Uint8Array(instance.exports.memory.buffer);
}
const ptr = instance.exports.alloc(1024); // may grow memory
const view = withView(instance); // built AFTER the alloc — safe
view.set(payload, ptr);
In hot paths, rebuild only when you detect a change rather than on every access:
let buffer = instance.exports.memory.buffer;
let u8 = new Uint8Array(buffer);
function refresh() {
if (instance.exports.memory.buffer !== buffer) { // identity change == grew
buffer = instance.exports.memory.buffer;
u8 = new Uint8Array(buffer);
}
return u8;
}
instance.exports.process(); // may grow
refresh()[0]; // re-reads only if the buffer was replaced
The identity check instance.exports.memory.buffer !== buffer is exact: a non-growing call returns the
same buffer object, so you skip the allocation; a growing call returns a new object, so you rebuild. This
is the pattern wasm-bindgen uses internally — its generated glue caches cachedUint8Memory and
invalidates it whenever the buffer changes, which is why you rarely see this bug through generated bindings
but always see it with a hand-written ABI like the one in
passing complex types across the boundary.
There is a tempting middle-ground that does not work: holding onto a pointer (an integer offset) is
perfectly safe across a grow, so people assume holding a view is too, since “it’s the same memory.” It is
the same logical memory, but not the same JavaScript object, and views are bound to objects. The right
mental separation is to treat pointers as the durable currency you pass and store, and views as disposable,
throwaway windows you mint at the moment of use and discard immediately after. Concretely: store
ptr, never store view. Any time you need to touch bytes at ptr, construct
new Uint8Array(instance.exports.memory.buffer, ptr, len) right there, use it, and let it go out of scope.
A view that never outlives a single synchronous block of byte access can never go stale, because no
boundary call — and therefore no possible grow — happens during its lifetime. This discipline is more
robust than the identity check for code that is not performance-critical, because it removes the cached
state entirely.
The danger zone is specifically across an await. If you build a view, then await something, then keep
using the view, a different task may have called into Wasm and grown memory while you were suspended. Async
code makes the “any call may grow” rule harder to reason about because the call that grows might not be one
you wrote on that line. Re-mint views after every await that could have yielded to Wasm-touching code.
How shared memory differs
A memory created with shared: true (backed by a SharedArrayBuffer) behaves differently. By spec, a
shared memory grows in place: memory.grow does not detach the buffer, because other threads hold
references to it and detaching would break them. Instead the same SharedArrayBuffer reports a larger
byteLength after growth, and existing views remain valid (though a view constructed with an explicit
length still only covers its original range — you still need a fresh full-length view to see the new
region).
const shared = new WebAssembly.Memory({ initial: 1, maximum: 10, shared: true });
const view = new Uint8Array(shared.buffer);
console.log(view.byteLength); // 65536
shared.grow(1);
console.log(view.byteLength); // 131072 — same buffer, now larger; NOT detached
So the detached-view bug is specific to non-shared memory. If your module uses SharedArrayBuffer and threading, growth is safe for existing views — but you pay for it with the cross-origin isolation requirements that shared memory imposes.
Gotchas
- Reads fail silently, writes throw. Reading a detached view yields
undefined; writing throwsTypeError: ... on a detached ArrayBuffer. The silent read is what makes this bug hard to localize. - Even a tiny alloc can grow. If the heap happens to be one byte from a page boundary, a 4-byte alloc triggers a grow. You cannot predict it from allocation size — assume every alloc may grow.
- Caching the
bufferreference is just as stale as caching the view. Both must be re-read after a potentially-growing call. DataViewdetaches too. This is not specific toUint8Array; every view type over the buffer —Float64Array,DataView,Int32Array— detaches identically.
Performance note
Rebuilding a typed-array view is nearly free — it allocates a small wrapper object over an existing buffer, not the bytes — so the identity-check-and-rebuild pattern costs essentially nothing on the common no-growth path and only one small allocation when growth actually occurs. There is no reason to risk a stale view to save it.
Frequently Asked Questions
Do Wasm-internal pointers also become invalid after memory.grow?
No. A Wasm pointer is a byte offset; the engine copies the old contents into the new buffer, so offset 100
still refers to the same logical byte. Only JavaScript views over the old ArrayBuffer break. This
asymmetry — valid pointers, invalid views — is the whole point of confusion.
How do I detect that a grow happened?
Compare buffer identity: instance.exports.memory.buffer !== savedBuffer. A non-growing call returns the
same object; a growing call returns a new one. You can also watch memory.buffer.byteLength increase.
Does this happen with shared memory too?
No. A SharedArrayBuffer-backed memory grows in place and does not detach, so existing views stay valid.
The detached-view bug is unique to ordinary non-shared linear memory.
Related
- Linear memory management & allocators — memory.grow semantics and the allocator that triggers it.
- Implementing a bump allocator in Wasm — an allocator that grows memory mid-bump.
- SharedArrayBuffer, Atomics & threading — shared memory that grows without detaching.
← Back to Linear Memory Management & Allocators