Creating Views into Wasm Memory Safely

This page answers one task: JavaScript reads and writes data inside a WebAssembly module’s memory through typed-array views — the core of every zero-copy design — and those views must never point at detached buffers, freed regions or the wrong offsets.

Prerequisites

Three ways a view goes wrong

A view such as new Float32Array(memory.buffer, ptr, n) is a window onto bytes inside linear memory. It costs nothing to create and avoids copying, which is why zero-copy designs rely on it. But it is only as valid as three assumptions.

The buffer must still be the current one. When memory grows, non-shared memories replace their ArrayBuffer: the old one is detached and every view over it reads as empty — length zero, elements undefined. Any call into the module that allocates can grow memory, so a view created before such a call may be dead after it. The region must still belong to what you think it does: once the module frees and reuses the memory, the view shows someone else’s data. And the offset must be valid for the element type: a Float32Array view needs a byte offset that is a multiple of 4, or the constructor throws.

Why a view into Wasm memory returns wrong data If the view's length is zero, memory grew and the buffer was detached. If values look like other data, the region was freed and reused. If the constructor throws a RangeError, the offset is not aligned for the element type. The view looks wrong — which failure is it? length 0 / undefined values memory grew — the buffer was detached plausible but wrong values region freed and reused — ownership bug RangeError on creation offset not aligned for the element size

Step 1 — create views late and use them immediately

The simplest rule prevents most bugs: create a view right before using it, and do not keep it across calls into the module.

function readResult(wasm) {
  const ptr = wasm.result_ptr();
  const len = wasm.result_len();
  return new Float32Array(wasm.memory.buffer, ptr, len).slice();   // view, then copy out immediately
}

Creating a typed array over an existing buffer takes only tens of nanoseconds, so there is no performance reason to cache views across calls. If a hot loop needs the same view many times, create it once per batch, after the last call that might allocate.

Step 2 — wrap view creation in a helper

A helper centralises the checks so callers cannot get them wrong:

export function view(memory, Type, ptr, length) {
  const buf = memory.buffer;
  if (ptr % Type.BYTES_PER_ELEMENT !== 0) throw new RangeError(`unaligned ${Type.name} at ${ptr}`);
  if (ptr + length * Type.BYTES_PER_ELEMENT > buf.byteLength) throw new RangeError(`view past end of memory`);
  return new Type(buf, ptr, length);
}

const samples = view(wasm.memory, Float32Array, ptr, n);

Always reading memory.buffer at call time guarantees the current buffer. The explicit checks turn silent bugs — a length computed in bytes instead of elements, a pointer of zero from a failed allocation — into clear exceptions at the point of creation.

In TypeScript, give the helper a generic signature over typed-array constructors so the element type flows through to callers, and keep it in one shared module used by every wrapper in the project. A single implementation means a single place to add checks later — for instance, verifying in debug builds that the region lies inside an allocation the module reported as live.

Step 3 — cache views safely when you must

Generated glue caches views for speed and revalidates them before each use. Do the same for long-lived code:

let f32 = null;
function f32view(memory) {
  if (f32 === null || f32.buffer !== memory.buffer) f32 = new Float32Array(memory.buffer);
  return f32;
}

f32view(wasm.memory).set(input, ptr >>> 2);

Comparing f32.buffer !== memory.buffer detects replacement after growth. Checking byteLength === 0 also works for non-shared memory, but the identity check also covers shared memory, whose old buffers are not detached but do not include newly grown pages.

A cached view surviving memory growth JavaScript caches a view. A call into the module allocates and grows memory, replacing the buffer. Before the next use, the helper compares the cached view's buffer with memory.buffer, sees they differ, and creates a new view over the current buffer. JavaScript view helper Wasm module f32view() — creates view A process() — allocates, memory grows buffer replaced; view A detached f32view() — buffer changed new view B over current buffer

Step 4 — make ownership explicit

A pointer returned by the module is only meaningful while the module keeps that memory for you. Agree on ownership for every pointer-returning function:

  • Borrowed until the next call. Common for “last result” buffers. Read or copy before calling anything else.
  • Owned by the caller. JavaScript must call a free function when done, typically in finally.
  • Owned by a long-lived object. Valid while the object lives — for example, a wasm-bindgen struct exposing data_ptr() and len().

Document the rule next to each export and encode it in the wrapper: a function returning a borrowed buffer should return a copy or accept a callback that receives a view and must not keep it.

export function withPixels(img, fn) {
  const v = view(wasm.memory, Uint8ClampedArray, img.pixels_ptr(), img.pixels_len());
  return fn(v);                  // view valid only inside fn
}

Step 5 — debug stale views

When a view misbehaves, check view.buffer.byteLength (zero means detached), compare view.buffer === memory.buffer, and log the pointer and length next to the module’s own idea of them. In debug builds, a module can fill freed memory with a pattern (0xDD bytes) so that reads through a view of freed memory show the pattern instead of plausible data. Emscripten’s -sSAFE_HEAP and sanitizer builds catch the module’s side of the same bugs, as in catching memory bugs with Emscripten sanitizers.

Views over shared memory

Threaded modules use shared memory, and views over it behave differently in two ways. Growth does not detach the old buffer: existing views remain valid, but keep their original length and cannot see new pages, so the identity check in step 3 is still needed to reach memory added later. And other threads may be writing to the same bytes while JavaScript reads them, so plain typed-array reads can observe partial updates. Use Atomics.load and Atomics.store on Int32Array or BigInt64Array views for values that change concurrently, and rely on ownership protocols — double buffering, ring buffers — for bulk data, as in implementing a lock-free ring buffer in shared memory. TextDecoder refuses views over shared memory in some browsers; copy the bytes into a non-shared buffer first, as described in decoding strings directly from Wasm memory.

Writing through views

Everything above applies to writes as well as reads, with one extra hazard: writing through a stale view silently does nothing, so data JavaScript believes it handed to the module never arrives. The module then processes zeros or leftover data, and the bug looks like a logic error in Rust or C. The safest pattern for input is to allocate first, then create the view, then write, then call — in that order, with no other module calls in between:

const ptr = wasm.alloc(input.length * 4);                  // may grow memory
view(wasm.memory, Float32Array, ptr, input.length).set(input);
wasm.process(ptr, input.length);

Allocating after creating the view is the classic mistake: the allocation grows memory, the view detaches, and set writes nowhere. Keeping these four steps together in one helper function per export makes the order impossible to get wrong.

Expected output

A test that grows memory between creating and using a view fails clearly with the helper’s error instead of silently reading zeros; after switching to late-created views and the identity-checked cache, a long session with frequent memory growth shows no detached-buffer errors.

Gotchas

  • Caching memory.buffer in a variable. It becomes stale after growth. Read memory.buffer each time.
  • Byte offsets versus element indices. f32[ptr] is wrong; use f32[ptr >>> 2] or create the view at ptr.
  • Returning views to callers. They may outlive the memory region. Return copies, or scope views to a callback.
  • Unaligned offsets. Float64Array needs multiples of 8. Allocate with the right alignment.
  • Allocating after creating a view. The allocation may grow memory and detach the view. Allocate first.
  • Reading shared memory without atomics. Concurrent writes produce torn values. Use Atomics or buffer ownership.

Performance note

Creating a Float32Array view over existing memory took about 40 ns in Chrome; the identity-checked cache cost about 2 ns per access. Copying a 4 MB result out with slice() took 0.7 ms, so views save real time on large data — provided they are created safely.

Cost of obtaining a usable view Nanoseconds per access to get a usable typed-array view over Wasm memory, creating a fresh view each time, using an identity-checked cached view, and copying 4 KB out instead. ns per access create a fresh view 40 ns identity-checked cache 2 ns copy 4 KB out with slice 380 ns

Frequently Asked Questions

Does subarray copy? No — it creates another view over the same buffer, with the same staleness rules.

Can I prevent memory growth while a view is in use? Not directly. Avoid calling allocating exports while the view is in use, or reserve memory up front.

Is DataView safer? It has the same buffer-detachment problem, but accepts unaligned offsets.

Do these rules apply to Emscripten’s HEAPU8? Yes. Emscripten updates HEAPU8 and friends after growth; always access them through Module rather than caching them locally.

How do I know whether a call can grow memory? Assume any call that allocates can. Pure functions over existing buffers usually cannot, but treating every call as a potential growth point is safer.

Can a view outlive the module instance? It keeps the old memory alive as long as it is referenced, which can pin a large discarded instance in memory. Drop views with the instance.

← Back to Zero-Copy Data Transfer Patterns