Reading Wasm Linear Memory with Typed Arrays
This guide answers one task: given a pointer a WebAssembly module returned, read the bytes it points at from JavaScript without copying them — including decoding a packed struct field by field with the right widths and byte order.
Prerequisites
- [ ] A
.wasmmodule that exportsmemoryand returns a pointer (byte offset) from at least one function. - [ ] Node.js 18+ or any modern browser with
WebAssembly.instantiate. - [ ] Comfort with
TypedArrayconstructors andDataViewgetters. - [ ] Knowledge of your module’s data layout (field offsets and widths) — typically from its Rust/C source.
How linear memory reads work
A module’s linear memory is a single ArrayBuffer exposed as instance.exports.memory.buffer. To read
it you construct a typed-array view at the offset you care about. The three-argument constructor
new Uint8Array(buffer, byteOffset, length) aliases the bytes in place — it allocates nothing and copies
nothing, so it is O(1) regardless of length. For mixed-width or endian-specific fields you use a
DataView, which lets you choose the width and byte order per read. WebAssembly stores multi-byte values
little-endian, so that is the byte order you decode with.
The distinction between a width-typed view and a DataView is worth internalizing before you write any
decoder. A width-typed view (Uint32Array, Float32Array) treats the buffer as a homogeneous array of
one element type, indexes it in element units rather than bytes, and inherits the platform’s native byte
order — which for every WebAssembly engine is little-endian. It is the fast path for a contiguous run of
identical values, but it imposes alignment: the byteOffset must be a multiple of the element size. A
DataView treats the buffer as an opaque sequence of bytes you address individually by absolute byte
offset, with the width and endianness chosen per call. It has no alignment requirement and no implicit
byte order, which makes it the correct tool for a packed struct whose fields sit at arbitrary offsets and
mixed widths. Real decoders use both: a DataView to walk the header, then a Float32Array aliasing the
aligned payload that follows.
Step-by-step procedure
-
Instantiate and capture
memory. Keep thememoryobject, not a stalebufferreference.const bytes = await fetch("/stats.wasm").then((r) => r.arrayBuffer()); const { instance } = await WebAssembly.instantiate(bytes); const { memory, compute_stats } = instance.exports; -
Call the function to get a pointer. The return value is a byte offset into
linear memory.const ptr = compute_stats(); // e.g. 65536 — offset of a packed struct -
Read
memory.bufferfresh. Always re-read it after a call; a grow may have replaced it.const buffer = memory.buffer; // current backing ArrayBuffer -
Build a byte view for a simple region. For a run of bytes (a string body, a pixel block), a
Uint8Arraywindow is enough.const headerLen = new DataView(buffer).getUint32(ptr, true); // little-endian length const bodyView = new Uint8Array(buffer, ptr + 4, headerLen); // aliases the body -
Use a
DataViewfor a mixed-layout struct. Suppose the module wrote this packed struct atptr:offset width field 0 u32 count (little-endian) 4 f32 mean (little-endian) 8 f64 variance (8-byte aligned) 16 u8 flagsDecode it field by field, passing
truefor little-endian:const dv = new DataView(buffer); const stats = { count: dv.getUint32(ptr + 0, true), mean: dv.getFloat32(ptr + 4, true), variance: dv.getFloat64(ptr + 8, true), flags: dv.getUint8(ptr + 16), }; -
Read a numeric array with a width-typed view. If the field is a contiguous
f32array, alias it directly — but only when the offset is correctly aligned (see gotchas).const samples = new Float32Array(buffer, ptr + 32, sampleCount); // ptr+32 must be %4 == 0
Expected output
Logging the decoded struct shows the values the module wrote, with no copy having occurred:
console.log(stats);
// { count: 1024, mean: 0.4982, variance: 0.08317, flags: 1 }
To eyeball the raw bytes — useful when a field looks wrong — dump the region as hex:
const raw = new Uint8Array(buffer, ptr, 24);
console.log([...raw].map((b) => b.toString(16).padStart(2, "0")).join(" "));
// 00 04 00 00 3b 11 ff 3e ... (little-endian: 0x00000400 = 1024)
The first four bytes 00 04 00 00 read little-endian are 0x00000400 = 1024, confirming the count
field and the byte order.
Reading the hex by hand is the fastest way to debug a decoder that returns plausible-but-wrong numbers.
Little-endian means the least-significant byte comes first, so the four bytes 00 04 00 00 are the
value 0x00 + 0x04·256 + 0x00·65536 + 0x00·16777216 = 1024. If you forgot the true argument and read
big-endian, the same bytes become 0x00040000 = 262144 — a value that is obviously wrong by three
orders of magnitude, which is exactly the kind of mismatch the hex dump makes visible at a glance. When a
float field looks wrong, dumping the four bytes and comparing them against what the module’s source says
it wrote tells you immediately whether the problem is endianness, a wrong offset, or a genuine
computation bug.
Gotchas
-
Wrong endianness gives garbage.
DataViewgetters default to big-endian when you omit the second argument. WebAssembly is little-endian, so you must passtrue.dv.getUint32(ptr)(notrue) on the bytes above yields0x00040000= 262144 instead of 1024. -
Detached buffer after grow returns zero-length. If any call grew
linear memory, an earlierUint8Arrayover the oldmemory.bufferis now detached:.lengthis 0 and reads yieldundefined. Always rebuild views frominstance.exports.memory.bufferafter a call that might allocate. -
byteOffsetalignment throwsRangeError.new Float32Array(buffer, ptr + 32, n)throwsRangeError: start offset of Float32Array should be a multiple of 4ifptr + 32is not a multiple of 4 (8 forFloat64Array). If the offset cannot be aligned, read each element withdv.getFloat32(off, true)in a loop —DataViewhas no alignment requirement. -
Length past the buffer end throws. Constructing a view whose
byteOffset + length × BYTES_PER_ELEMENTexceedsbuffer.byteLengththrowsRangeError: Invalid typed array length. Validate the length the module reported before trusting it.
Decoding a length-prefixed array
A common real layout is a length-prefixed array: a 32-bit count followed by that many elements. Read the
count with a DataView, then alias the payload with a width-typed view — checking alignment first.
const dv = new DataView(buffer);
const n = dv.getUint32(ptr, true); // element count
const payloadOffset = ptr + 4;
if (payloadOffset % 4 === 0) {
const values = new Float32Array(buffer, payloadOffset, n); // fast aliased read
console.log(values[0], values[n - 1]);
} else {
// unaligned: fall back to per-element DataView reads
const values = new Float32Array(n);
for (let i = 0; i < n; i++) values[i] = dv.getFloat32(payloadOffset + i * 4, true);
}
The aligned branch is pure aliasing — no bytes move. The fallback branch copies element by element, which is the price of an unaligned payload; if you control the module’s layout, pad the header so the payload starts on a 4- or 8-byte boundary and you stay on the fast path.
Performance note
View construction is O(1) and copy-free: new Uint8Array(buffer, ptr, len) stores only an offset and a
length, so aliasing a 16 MB region costs the same as aliasing 16 bytes. The only place bytes move is when
you call .slice(), Array.from(), or spread the view — reserve those for when you genuinely need an
independent copy that survives a buffer detach. Per-field DataView reads are individually cheap but add
up: decoding a million-element array element by element is far slower than aliasing it once as a
Float32Array, so prefer the aligned bulk view whenever the layout allows it.
When to re-read memory.buffer
A recurring mistake is caching memory.buffer once at startup and reusing that reference for the life of
the program. It works right up until a call grows linear memory, at which point the cached buffer is
detached and every view built over it reads zero-length. The safe habit is to read
instance.exports.memory.buffer fresh at the point of use — immediately before constructing a view — and
to treat any call that might allocate (anything that runs the module’s allocator) as a potential
invalidation point. The cost of re-reading the property is negligible; the cost of a detached-buffer bug
is a silent data corruption that only appears under memory pressure. If you find yourself passing a
buffer reference down through several layers of helper functions, that is a smell — pass the memory
object and re-derive buffer at each read site instead.
Frequently Asked Questions
Do I need a DataView, or can I use typed arrays for everything?
Use width-typed views (Uint32Array, Float32Array) when a field is a contiguous array of one type and
the offset is aligned — they are slightly faster to read in bulk. Use DataView for packed structs with
mixed widths, unaligned offsets, or when you need explicit endianness control. Most real decoders mix
both.
Why does the same view read different bytes after another call?
Either the module wrote new data at that pointer, or memory.grow detached the buffer your view was built
over. Re-read memory.buffer and rebuild the view after every call that could allocate; never cache the
buffer reference across such calls.
How do I read a string the module returns?
A string is usually a (ptr, len) pair of UTF-8 bytes. Alias them with new Uint8Array(buffer, ptr, len)
and decode with new TextDecoder().decode(view) — covered in
encoding strings across the boundary.
Related
- Avoiding copies when passing image buffers — in-place pixel reads and writes.
- Returning structs from Wasm to JavaScript — struct ABI and field layout.
- Why memory.grow invalidates pointers — the detached-buffer hazard in depth.
← Back to Zero-Copy Data Transfer Patterns