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 .wasm module that exports memory and returns a pointer (byte offset) from at least one function.
  • [ ] Node.js 18+ or any modern browser with WebAssembly.instantiate.
  • [ ] Comfort with TypedArray constructors and DataView getters.
  • [ ] 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

  1. Instantiate and capture memory. Keep the memory object, not a stale buffer reference.

    const bytes = await fetch("/stats.wasm").then((r) => r.arrayBuffer());
    const { instance } = await WebAssembly.instantiate(bytes);
    const { memory, compute_stats } = instance.exports;
  2. 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
  3. Read memory.buffer fresh. Always re-read it after a call; a grow may have replaced it.

    const buffer = memory.buffer;       // current backing ArrayBuffer
  4. Build a byte view for a simple region. For a run of bytes (a string body, a pixel block), a Uint8Array window is enough.

    const headerLen = new DataView(buffer).getUint32(ptr, true);   // little-endian length
    const bodyView = new Uint8Array(buffer, ptr + 4, headerLen);   // aliases the body
  5. Use a DataView for a mixed-layout struct. Suppose the module wrote this packed struct at ptr:

    offset  width  field
    0       u32    count        (little-endian)
    4       f32    mean         (little-endian)
    8       f64    variance     (8-byte aligned)
    16      u8     flags

    Decode it field by field, passing true for 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),
    };
  6. Read a numeric array with a width-typed view. If the field is a contiguous f32 array, 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. DataView getters default to big-endian when you omit the second argument. WebAssembly is little-endian, so you must pass true. dv.getUint32(ptr) (no true) on the bytes above yields 0x00040000 = 262144 instead of 1024.

  • Detached buffer after grow returns zero-length. If any call grew linear memory, an earlier Uint8Array over the old memory.buffer is now detached: .length is 0 and reads yield undefined. Always rebuild views from instance.exports.memory.buffer after a call that might allocate.

  • byteOffset alignment throws RangeError. new Float32Array(buffer, ptr + 32, n) throws RangeError: start offset of Float32Array should be a multiple of 4 if ptr + 32 is not a multiple of 4 (8 for Float64Array). If the offset cannot be aligned, read each element with dv.getFloat32(off, true) in a loop — DataView has no alignment requirement.

  • Length past the buffer end throws. Constructing a view whose byteOffset + length × BYTES_PER_ELEMENT exceeds buffer.byteLength throws RangeError: 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.

← Back to Zero-Copy Data Transfer Patterns