Passing Arrays Between JavaScript and Wasm

This page answers one task: a JavaScript Float32Array or Uint8Array needs to be processed by a WebAssembly function, and the result needs to come back as an array — correctly, without leaks, and without more copying than necessary.

Prerequisites

  • [ ] A module that exports a function operating on a pointer and length, or a wasm-bindgen function taking a slice.
  • [ ] Access to the module’s memory and allocator exports (malloc/free or wasm-bindgen’s generated glue).

Why arrays cannot simply be passed

WebAssembly functions accept only numbers: i32, i64, f32, f64. A JavaScript array lives in the JavaScript heap, which a Wasm function cannot see. To process an array, its contents must be in the module’s linear memory, and the function receives a pointer (an i32 offset) and a length. So passing an array always involves three steps: allocate a region in linear memory, copy the data in, and call the function with the pointer and length. Getting results out is the mirror image: the function writes into linear memory, and JavaScript reads the region — either copying it into a new JavaScript array or viewing it in place.

The copies are cheap for small arrays — memory bandwidth is measured in gigabytes per second — but they are not free for large ones, and the cleanup step (freeing the allocation) is easy to forget. Bindings generators automate the pattern; understanding it lets you choose when to avoid a copy.

Passing an array into Wasm and getting the result back JavaScript allocates space in linear memory, copies the typed array into it, and calls the export with the pointer and length. The export processes the data in place. JavaScript then copies the result out into a new typed array and frees the allocation. malloc(n * 4) ptr in linear memory HEAPF32.set(arr) copy in process(ptr, n) work in place slice(ptr, n) copy out free(ptr) release

Step 1 — the wasm-bindgen way

With wasm-bindgen, a &[f32] parameter and a Vec<f32> return value generate exactly the copy-in, copy-out glue:

#[wasm_bindgen]
pub fn normalize(samples: &[f32]) -> Vec<f32> {
    let peak = samples.iter().fold(0.0f32, |m, &s| m.max(s.abs())).max(1e-9);
    samples.iter().map(|s| s / peak).collect()
}
import init, { normalize } from "./pkg/audio.js";
await init();
const out = normalize(new Float32Array([0.1, -0.5, 0.25]));   // Float32Array [0.2, -1, 0.5]

The glue allocates, copies the input in, calls the function, copies the returned vector out into a new Float32Array and frees both regions. &mut [f32] goes further: wasm-bindgen copies the data in, and after the call copies the modified data back into the original JavaScript array, so in-place APIs work naturally.

Step 2 — the manual way, for Emscripten or raw exports

Without generated glue, write the same steps by hand:

function normalize(arr) {
  const bytes = arr.length * 4;
  const ptr = Module._malloc(bytes);
  try {
    Module.HEAPF32.set(arr, ptr >> 2);                         // copy in; index is in elements
    Module._normalize(ptr, arr.length);                        // C: void normalize(float *p, int n)
    return Module.HEAPF32.slice(ptr >> 2, (ptr >> 2) + arr.length);   // copy out
  } finally {
    Module._free(ptr);
  }
}

Two details are easy to get wrong. The typed-array index is in elements, so the byte pointer is shifted (ptr >> 2 for 4-byte floats). And slice copies while subarray creates a view — returning a subarray here would hand callers a view into memory that is freed immediately. Use slice for results that outlive the call. The finally ensures the allocation is freed even if the export traps.

Step 3 — process in place to save a copy

When the caller does not need the original data, the function can write its results over the input, saving the second allocation:

void normalize_in_place(float *p, int n) {
    float peak = 1e-9f;
    for (int i = 0; i < n; i++) peak = fmaxf(peak, fabsf(p[i]));
    for (int i = 0; i < n; i++) p[i] /= peak;
}

That still copies in and out, but uses half the linear memory. For repeated processing of similarly sized arrays — audio blocks, frames — allocate the buffer once and reuse it for every call, removing the allocation cost altogether.

Step 4 — skip the copy-out with a view

If JavaScript only needs to read the result briefly — to draw it, to sum it, to pass it to a Web API that copies anyway — a view into linear memory avoids the copy out:

const view = new Float32Array(Module.HEAPF32.buffer, ptr, n);   // no copy
ctx.putImageData(…);  // or audioBuffer.copyToChannel(view, 0)

The view is valid only until the memory grows or the region is freed or reused, so consume it immediately and never store it. When data needs to stay in Wasm memory between calls — a long-lived buffer that both sides work on — skip the copy-in as well by having JavaScript write directly into the module’s buffer. Those zero-copy patterns are developed in avoiding copies when passing image buffers and creating views into Wasm memory safely.

Copying arrays versus viewing Wasm memory Copying in and out is simple, safe and right for small arrays and one-off calls. A view into linear memory avoids the copy out but is valid only until memory grows or the region is freed. A persistent shared buffer avoids both copies but needs a clear ownership protocol. copy in, copy out safe, simple, generated by glue two copies per call fine below ~1 MB the default copy in, view out one copy per call view valid until growth or free consume immediately for large read-once results persistent buffer JS writes into Wasm memory directly no copies at all needs an ownership protocol for streaming and frames

Step 5 — handle arrays of other types

The same pattern applies to every numeric element type, with the right heap view and shift: HEAPU8 (no shift), HEAP16 (>> 1), HEAP32 and HEAPF32 (>> 2), HEAPF64 (>> 3), and HEAP64 with BigInt64Array for 64-bit integers. Arrays of structs need a layout both sides agree on, as described in aligning data in linear memory. Arrays of strings or objects have no direct representation in linear memory and must be serialised — see choosing between JSON and binary serialization. Plain JavaScript arrays ([1, 2, 3]) should be converted to a typed array first; wasm-bindgen accepts them for Vec<f64> parameters through slower generic conversion, element by element.

When the copy is the bottleneck

It is tempting to assume the copies are always negligible, but at scale they show up. Copying a 100 MB buffer in and another out costs tens of milliseconds, plus the time to allocate 200 MB of linear memory that, once grown, is never returned. For per-frame data in a real-time loop, two copies of a 4 MB frame at 60 frames per second move almost half a gigabyte per second through memory. Measure before optimising: time the whole call and the export alone, using the techniques in measuring JS-to-Wasm call overhead. If the copies are under a tenth of the total, leave them; simplicity is worth more. If they dominate, move to a persistent buffer, or restructure so the data is produced inside linear memory in the first place — decoded from a file directly into the module’s buffer rather than into a JavaScript array that is then copied.

Before reaching for zero-copy designs, check that the copies are happening only once. A common accidental cost is converting data twice: reading a file into an ArrayBuffer, wrapping it in a plain array for convenience, and then passing that to a slice parameter, which converts element by element back into bytes. Keep data in typed arrays from the source to the call, and the copy into linear memory is a single fast memory move.

Expected output

normalize(new Float32Array([0.1, -0.5, 0.25])) returns Float32Array [0.2, -1, 0.5]; calling it 100,000 times leaves memory.buffer.byteLength and the allocator’s live bytes unchanged.

Gotchas

  • Allocating per call in a loop. Reuse one buffer for repeated calls of similar size.
  • Byte offset used as an element index. HEAPF32[ptr] reads the wrong place. Shift by the element size.
  • Returning a subarray of freed memory. The caller reads garbage later. Use slice for results that outlive the call.
  • Forgetting free on the error path. Wrap calls in try … finally.
  • Holding a heap view across growth. Emscripten’s HEAPF32 is replaced after growth; re-read it from Module each time.
  • Endianness assumptions in DataView. If you read results with a DataView, pass true for little-endian.
  • Passing a plain array to a slice parameter. It works but converts element by element. Pass a typed array.

Performance note

For a 1 million-element Float32Array, copying in took 0.6 ms and copying out 0.7 ms in Chrome; the normalisation itself took 1.9 ms. Viewing the result instead of copying it out saved the 0.7 ms; a persistent buffer reused across calls removed both copies and the allocation, bringing the total to the 1.9 ms of actual work.

Total time to normalise one million floats Milliseconds for one call including data movement, with copy in and copy out, copy in with a view out, and a persistent buffer that JavaScript writes into directly. ms per call including transfers copy in + copy out 3.4 ms copy in + view out 2.7 ms persistent buffer 1.9 ms

Frequently Asked Questions

Can Wasm read a JavaScript typed array directly, without copying? No. Wasm can only access its own linear memory. The data must be in that memory, either copied or produced there.

Is set faster than a loop? Much faster. TypedArray.prototype.set copies with a memory-move, while a JavaScript loop copies element by element.

Does wasm-bindgen copy &[u8] parameters? Yes, into linear memory for the duration of the call. Results returned as Vec are copied out.

What about arrays larger than memory? Process them in chunks, streaming each chunk through a fixed-size buffer.

Can I pass a SharedArrayBuffer to a function? Only if the module’s memory itself is that shared buffer; otherwise it must be copied like any other array.

Why does Emscripten’s HEAPF32 sometimes have length zero? Memory grew and the old view was detached. Read Module.HEAPF32 again; the glue replaces it after growth.

← Back to Passing Complex Types Across the Boundary