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 memory and a function that calls memory.grow
  • [ ] Familiarity with the linear memory modelpage size and how memory.grow works
  • [ ] Knowing that typed arrays alias an ArrayBuffer rather 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:

  1. A new, larger ArrayBuffer is created; instance.exports.memory.buffer now returns it.
  2. The old ArrayBuffer is detached — byteLength becomes 0, reads return nothing.
  3. 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 throws TypeError: ... 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 buffer reference is just as stale as caching the view. Both must be re-read after a potentially-growing call.
  • DataView detaches too. This is not specific to Uint8Array; 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.

← Back to Linear Memory Management & Allocators