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
- [ ] A module exposing its memory and functions that return pointers and lengths.
- [ ] Familiarity with reading Wasm linear memory with typed arrays.
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.
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.
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()andlen().
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.bufferin a variable. It becomes stale after growth. Readmemory.buffereach time. - Byte offsets versus element indices.
f32[ptr]is wrong; usef32[ptr >>> 2]or create the view atptr. - Returning views to callers. They may outlive the memory region. Return copies, or scope views to a callback.
- Unaligned offsets.
Float64Arrayneeds 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
Atomicsor 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.
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.
Related
- Why memory.grow invalidates pointers — the growth mechanism.
- Growing memory safely from JavaScript — growth initiated by the host.
- Aligning data in linear memory — alignment requirements for views.
- Avoiding copies when passing image buffers — views in a real workload.
← Back to Zero-Copy Data Transfer Patterns