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/freeor 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.
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.
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
subarrayof freed memory. The caller reads garbage later. Useslicefor results that outlive the call. - Forgetting
freeon the error path. Wrap calls intry … finally. - Holding a heap view across growth. Emscripten’s
HEAPF32is replaced after growth; re-read it fromModuleeach time. - Endianness assumptions in
DataView. If you read results with aDataView, passtruefor 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.
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.
Related
- Reading Wasm linear memory with typed arrays — the views used above.
- Returning structs from Wasm to JavaScript — structured results.
- Encoding strings across the Wasm boundary — the same pattern for text.
- Passing audio samples without copying — arrays in a real-time loop.