Using DataView over Wasm Memory

This page answers one task: a WebAssembly module stores records in linear memory — structs with a mix of u8, u32, f64 and i64 fields — and JavaScript needs to read or write them without converting through wasm-bindgen for every field. Typed arrays handle one element type at a time; you want a clean way to access mixed layouts directly.

Prerequisites

  • [ ] A module that exposes the address of a struct or an array of structs.
  • [ ] The exact memory layout of those structs (#[repr(C)] in Rust, a C struct definition).
  • [ ] Access to memory.buffer from JavaScript.

Typed arrays versus DataView

A typed array (Float64Array, Uint32Array) views memory as a sequence of one element type, at an aligned offset: element i of a Float64Array at byte offset o lives at o + 8i, and o must be a multiple of 8. That is ideal for arrays of numbers, and engines optimise typed-array access heavily. A DataView views the same memory as raw bytes and offers typed getters and setters at any byte offset — getUint8, getUint32, getFloat64, getBigInt64 — with an explicit endianness argument. That suits records whose fields have different types and offsets, and data whose fields are not aligned (packed formats, file headers, network messages).

WebAssembly memory is little-endian. DataView defaults to big-endian, so every multi-byte access must pass true for little-endian — the single most common bug with DataView over Wasm memory.

Typed arrays versus DataView over linear memory Typed arrays view memory as one element type at aligned offsets with very fast indexed access, suited to numeric arrays. DataView reads and writes any type at any byte offset with explicit endianness, suited to mixed-type records and packed formats, at somewhat lower speed. typed arrays one element type aligned offsets only fastest access numeric arrays DataView any type, any offset explicit little-endian slightly slower per access mixed records, packed data

Step 1 — know the layout exactly

#[repr(C)]
pub struct Particle {
    pub kind: u8,        // offset 0
    // 3 bytes padding
    pub id: u32,         // offset 4
    pub x: f64,          // offset 8
    pub y: f64,          // offset 16
    pub born_ns: i64,    // offset 24
}                        // size 32, align 8

#[no_mangle]
pub extern "C" fn particles_ptr() -> *const Particle { PARTICLES.as_ptr() }
#[no_mangle]
pub extern "C" fn particles_len() -> usize { PARTICLES.len() }

#[repr(C)] fixes field order and padding to C rules. Without it, Rust may reorder fields. Verify offsets with core::mem::offset_of! in a test, and export them if the layout might change.

Step 2 — read records with DataView

const LAYOUT = { size: 32, kind: 0, id: 4, x: 8, y: 16, bornNs: 24 };

function readParticle(memory, base, i) {
  const dv = new DataView(memory.buffer);         // fresh view: memory may have grown
  const o = base + i * LAYOUT.size;
  return {
    kind: dv.getUint8(o + LAYOUT.kind),
    id: dv.getUint32(o + LAYOUT.id, true),          // true = little-endian
    x: dv.getFloat64(o + LAYOUT.x, true),
    y: dv.getFloat64(o + LAYOUT.y, true),
    bornNs: dv.getBigInt64(o + LAYOUT.bornNs, true),
  };
}

For reading many records, create one DataView per batch rather than per record, and re-create it after any call into the module that may allocate.

Step 3 — write fields in place

Setters write directly into linear memory, which the module sees immediately:

function moveParticle(memory, base, i, dx, dy) {
  const dv = new DataView(memory.buffer);
  const o = base + i * LAYOUT.size;
  dv.setFloat64(o + LAYOUT.x, dv.getFloat64(o + LAYOUT.x, true) + dx, true);
  dv.setFloat64(o + LAYOUT.y, dv.getFloat64(o + LAYOUT.y, true) + dy, true);
}

Writing from JavaScript into memory the module also writes is safe on a single thread as long as calls do not interleave; with threads, it needs the same synchronisation as any shared data.

Layout of one Particle record A 32-byte record with a one-byte kind at offset 0, three bytes of padding, a four-byte id at offset 4, an eight-byte x at offset 8, an eight-byte y at offset 16 and an eight-byte birth time at offset 24. struct Particle (#[repr(C)], 32 bytes, little-endian) kind pad id x (f64) y (f64) born_ns (i64) 0 4 8 16 24 32

Step 4 — combine DataView and typed arrays for speed

For hot loops over many records, a strided typed-array view is faster than DataView: because all f64 fields are 8-byte aligned, a Float64Array over the records’ region lets you read x of record i at index (i * 32 + 8) / 8 = i * 4 + 1:

const f64 = new Float64Array(memory.buffer, base, (count * LAYOUT.size) / 8);
for (let i = 0; i < count; i++) {
  const x = f64[i * 4 + 1], y = f64[i * 4 + 2];
  draw(x, y);
}

Use DataView for mixed-type and unaligned access, and strided typed arrays for bulk numeric fields. When JavaScript reads mostly one field across many records, a struct-of-arrays layout in the module (separate xs, ys arrays) is better still.

Step 5 — keep layouts in sync

Hand-written offsets break silently when someone adds a field. Generate the JavaScript layout from the source of truth: export the offsets and size from the module (offset_of! values returned by a layout() function) and build LAYOUT at startup, or generate a TypeScript file from the Rust or C definitions in the build. A startup assertion that LAYOUT.size equals the module’s size_of::<Particle>() catches drift immediately.

Packed and unaligned formats

File formats and network protocols often pack fields without padding: a u8 followed immediately by a u32 at offset 1. Typed arrays cannot read that u32 (offset not a multiple of 4); DataView can. When a module parses such formats and exposes the raw bytes, DataView is the natural tool for JavaScript to inspect headers, and Rust can match with #[repr(C, packed)] — though taking references to packed fields in Rust is unsafe and should be avoided in favour of reading them by value with read_unaligned.

64-bit values

getBigInt64 and getBigUint64 return BigInts, which are exact but slower and do not mix with numbers. If a 64-bit field holds values that always fit in 53 bits (counts, millisecond timestamps), reading two 32-bit halves and combining them as hi * 2**32 + lo gives a regular number faster; if values can exceed 2^53, keep BigInt.

A small accessor layer instead of scattered offsets

Offsets sprinkled through application code are hard to change and easy to get wrong. Wrap each record type in a small accessor class — a “flyweight” that holds the base address and index and exposes getters and setters implemented with DataView — so the rest of the code reads p.x and p.id as if they were ordinary properties. One accessor object can be reused while iterating (set its index, read fields, move on), which avoids allocating a JavaScript object per record. The class is also the one place that knows the layout, so it can be generated from the module’s exported offsets at startup or from a schema at build time. Libraries exist that do this from a struct description, but a hand-written class of a few dozen lines per record type is often clearer and fast enough.

Testing layout agreement

Layout bugs show up as plausible but wrong numbers, which are hard to spot. Add a test that creates a record in the module with distinctive values in every field — for example kind = 0x7f, id = 0x12345678, x = 1.5, y = -2.25, born_ns = 2^40 + 3 — then reads it from JavaScript through the accessor and asserts every field. Repeat in the other direction: write every field from JavaScript and have a module function return them for comparison. Run the test whenever either side changes; it pins the layout contract in a way reviewers can see, and it catches endianness, padding and offset mistakes immediately.

Records arriving from files or the network

DataView is also the right tool before data reaches the module: inspecting a binary file header to decide which decoder to use, reading a message’s length prefix to know how many bytes to copy into linear memory, or validating a magic number. Parsing such headers in JavaScript with DataView and handing only the payload to Wasm keeps the boundary simple.

Expected output

JavaScript reads 10,000 Particle records with DataView and gets correct id, x, y and bornNs values, matching the module’s own output; drag edits write x and y in place; the render loop reads positions through a strided Float64Array; and a startup check confirms the JavaScript layout matches the module’s size_of and offsets.

Gotchas

  • Forgetting little-endian. DataView defaults to big-endian. Always pass true.
  • Rust structs without #[repr(C)]. Field order is unspecified. Fix the layout.
  • Hand-maintained offsets. They drift. Generate or verify them at startup.
  • Stale DataView after memory growth. It detaches. Re-create after allocating calls.
  • DataView in the hottest loops. Strided typed arrays are faster for aligned numeric fields.

Performance note

Reading the x and y fields of one million records took about 9 ms with DataView and about 2 ms with a strided Float64Array in a current desktop browser; for mixed-type reads of all five fields, DataView was the simplest correct option at about 20 ms.

Reading one million records from linear memory Milliseconds to read x and y from one million 32-byte records using DataView getters and using a strided Float64Array view. ms per million records DataView getters 9 ms strided Float64Array 2 ms

Frequently Asked Questions

Is DataView slow? It is well optimised in modern engines, but typed-array indexing is usually faster for aligned numeric data.

Can DataView view shared memory? Yes, over a SharedArrayBuffer; atomic access still requires typed arrays and Atomics.

What about structs with strings? Store a pointer and length in the struct and decode the bytes with TextDecoder.

Does wasm-bindgen generate layouts for me? No — it converts values rather than exposing layouts; this technique bypasses it deliberately.

How do I avoid allocating an object per record when reading many? Use one reusable accessor object, update its index while iterating, and read fields through its DataView-backed getters.

← Back to Zero-Copy Data Transfer Patterns