JS/Wasm Interop & Memory Management
WebAssembly only computes; it cannot fetch, paint, or allocate on its own. Every byte that crosses
between JavaScript and a compiled module passes through one narrow channel — a shared linear memory
buffer and a handful of integer-only function signatures. This area covers how to drive that channel
correctly: generating glue with wasm-bindgen, sharing memory across threads with SharedArrayBuffer
and Atomics, moving large payloads without copying, and managing the heap inside the module so
pointers stay valid.
For performance engineers, the boundary is where Wasm’s near-native speed is won or lost. A function that runs 8× faster than JavaScript is worthless if you spend the savings re-encoding a string on every call. Master the ABI, the memory model, and the copy semantics, and you keep the speedup.
Engineering takeaways
- Read and write
linear memoryfrom JavaScript using typed-array views, and know exactly when those views become detached. - Marshal strings, structs, and typed arrays across the boundary with predictable, documented ABI conventions instead of guesswork.
- Generate type-safe glue with
wasm-bindgenand understand the JavaScript it emits, so you can debug and optimize it. - Share a single memory between the main thread and Web Workers using
SharedArrayBufferand coordinate withAtomics— the foundation of Wasm threads. - Move megabyte-scale buffers (images, audio, tensors) with zero copies, turning marshaling from a bottleneck into a pointer hand-off.
- Reason about allocator behaviour —
malloc,free, bump allocators, and whymemory.growinvalidates every existing view and pointer.
The boundary contract: integers in, bytes through memory
A WebAssembly function signature can only accept and return numbers — i32, i64, f32, f64, and
(with the reference-types proposal) opaque externref handles. There is no native “string”, “array”, or
“object” type at the boundary. Everything else is an encoding convention layered on top of two
primitives: passing an integer, and reading or writing bytes in the shared linear memory.
That memory is a single resizable ArrayBuffer. JavaScript reaches into it by constructing a typed-array
view at a known byte offset; the module reaches into it with load and store instructions. The integer
you pass across a call is almost always a pointer — a byte offset into that buffer — usually paired with
a length.
(module
(memory (export "memory") 1) ;; one 64 KiB page, exported to JS
;; sum the bytes in [ptr, ptr+len) — args are plain i32 offsets/counts
(func (export "sum_bytes") (param $ptr i32) (param $len i32) (result i32)
(local $i i32) (local $acc i32)
(block $done
(loop $loop
(br_if $done (i32.ge_u (local.get $i) (local.get $len)))
(local.set $acc
(i32.add (local.get $acc)
(i32.load8_u (i32.add (local.get $ptr) (local.get $i)))))
(local.set $i (i32.add (local.get $i) (i32.const 1)))
(br $loop)))
(local.get $acc)))
On the JavaScript side you write your data into that memory, then call the function with the offset and
length. The same mental model underpins the stack-based VM execution model — the value stack holds the integer operands, while the heap region of linear memory holds the bytes those operands point at.
const { instance } = await WebAssembly.instantiateStreaming(fetch("/sum.wasm"));
const mem = new Uint8Array(instance.exports.memory.buffer);
const data = new TextEncoder().encode("WebAssembly");
mem.set(data, 0); // write bytes at offset 0
const total = instance.exports.sum_bytes(0, data.length);
How you choose those offsets, who owns them, and how strings and structs get serialized into that buffer is the subject of passing complex types across the boundary, which formalizes the ABI for non-primitive values.
Generated glue: what wasm-bindgen does for you
Hand-writing the encode/decode dance for every function is tedious and error-prone, which is why the
Rust ecosystem leans on wasm-bindgen. When you annotate a Rust function with #[wasm_bindgen], the
tool generates a JavaScript shim that performs exactly the marshaling shown above — copying strings into
linear memory, passing the pointer/length pair, and decoding return values — and a .d.ts file so the
boundary is typed.
use wasm_bindgen::prelude::*;
#[wasm_bindgen]
pub fn greet(name: &str) -> String {
format!("Hello, {name}!")
}
The generated .js glue allocates space in the module’s heap, copies the UTF-8 bytes of name in,
calls the raw export with (ptr, len), then reads the returned (ptr, len) back out and builds a
JavaScript string — freeing both allocations as it goes. Understanding that emitted code is the
difference between treating wasm-bindgen as a black box and being able to profile it; the
wasm-bindgen deep dive walks through the
generated shim line by line and shows how JsValue, serde-wasm-bindgen, and web-sys extend the same
mechanism to whole objects and browser APIs.
This builds directly on the toolchain — wasm-bindgen runs as a post-processing step after the raw
.wasm is produced, which is why wasm-pack for Rust compilation bundles it into a single wasm-pack build. The same generated bindings feed your
ESM module generation pipeline,
so the typed wrapper is what your application actually imports.
Sharing one memory across threads
By default each Wasm instance owns a private linear memory. To run real threads you instead create a
shared memory backed by a SharedArrayBuffer, hand the same buffer to every Web Worker, and
instantiate the module in each worker against that one memory. Now all threads see the same bytes, and you
coordinate with Atomics — Atomics.wait, Atomics.notify, and atomic read-modify-write operations —
to avoid data races.
// Shared across the main thread and every worker
const memory = new WebAssembly.Memory({ initial: 16, maximum: 256, shared: true });
const i32 = new Int32Array(memory.buffer); // Int32Array, not Uint8Array, for Atomics
// One worker waits until the main thread bumps a flag at index 0
Atomics.store(i32, 0, 0);
worker.postMessage({ memory }); // structured clone shares, does not copy
// ... later, on the main thread:
Atomics.store(i32, 0, 1);
Atomics.notify(i32, 0, 1); // wake one waiter
Shared memory is also the only way to avoid postMessage structured-clone overhead for large payloads.
Because a SharedArrayBuffer is required and it exposes a timing side channel, browsers gate it behind
cross-origin isolation: your document must be served with Cross-Origin-Opener-Policy: same-origin and
Cross-Origin-Embedder-Policy: require-corp. Getting those headers right locally is covered in
configuring COOP/COEP headers,
and the full threading model — pthread pools, wasm-bindgen-rayon, and atomic synchronization patterns —
lives under SharedArrayBuffer, Atomics & threading.
Performance & tradeoffs: the cost of crossing
The boundary itself is cheap — a Wasm call from JavaScript costs a few nanoseconds in modern engines. The
expensive part is data movement. Every time you copy a buffer into linear memory and back out, you pay
memory-bandwidth cost proportional to the payload size, and you generate garbage for the JavaScript GC.
The decisive optimization is zero-copy: instead of copying an image or audio buffer in, you allocate space in the module’s heap once, get a view over it, and let the module write results in place. A 4 MB RGBA frame copied twice per call (in and out) at ~10 GB/s costs roughly 0.8 ms of pure memcpy — often more than the actual computation. Eliminating those copies is the single highest-leverage change for media and numeric workloads, and the patterns are catalogued in zero-copy data transfer patterns.
Three rules of thumb govern the tradeoff:
- Batch the boundary. One call processing 10,000 elements beats 10,000 calls processing one element — per-call marshaling dominates at small sizes.
- Keep hot data resident. If the module operates on the same buffer repeatedly, allocate it once in
linear memoryand reuse the pointer rather than re-uploading each frame. - Prefer views over copies. A
Uint8Array(memory.buffer, ptr, len)aliases the bytes for free; a.slice()copies them. Use the former unless you specifically need an independent snapshot.
There is a real tension with async patterns: a long Wasm computation blocks whatever thread runs it. On the main thread that is jank; the fix is to run the module in a worker — which then reintroduces the data-transfer question that shared memory answers. AOT-compiled engines make the compute fast, but they cannot make a copy free, so memory layout, not instruction selection, is usually the ceiling.
Security & sandboxing at the boundary
The interop layer is also the trust boundary. A module has exactly the capabilities you hand it through the
import object and nothing more — no DOM, no network, no filesystem — so the boundary is where you decide
what the sandbox can touch. The same isolation that makes Wasm safe also constrains memory sharing: pointers
are offsets into the module’s own buffer, never raw machine addresses, and every load/store is
bounds-checked, so a bad pointer traps instead of corrupting the host. The deeper model is laid out in
browser sandbox & security boundaries.
Cross-origin isolation deserves special care precisely because it is a relaxation of the sandbox. Enabling
SharedArrayBuffer via COOP/COEP re-grants the high-resolution timers that Spectre-class attacks need, so the
headers exist to ensure every resource on the page has opted in. Treat shared memory as a privilege: scope it
to the workers that need it, validate every offset and length you receive from JavaScript before using it as a
pointer, and never trust a length field to be in bounds — a malicious or buggy caller will hand you one that
is not.
Explore this area
This area is organized into five guides, each going deep on one part of the boundary:
- wasm-bindgen deep dive — how the generated glue marshals strings, objects, and closures, and how to read and optimize it.
- SharedArrayBuffer, Atomics & threading — shared memory, atomic synchronization, Web Worker pools, and cross-origin isolation.
- Zero-copy data transfer patterns —
typed-array views over
linear memoryand moving images, audio, and tensors without copies. - Passing complex types across the boundary — ABI conventions for strings, structs, and return values beyond plain integers.
- Linear memory management & allocators —
malloc/free, bump allocators, and whymemory.growinvalidates pointers and views.
Frequently Asked Questions
Why can’t I just pass a JavaScript object to a Wasm function?
Because a Wasm function signature only accepts numbers. An object has no numeric representation the engine
can pass directly, so you either serialize its fields into linear memory and pass a pointer, or — with the
reference-types proposal — pass it as an opaque externref handle the module can hold but not inspect.
Tools like wasm-bindgen automate the serialization so it looks like you are passing the object directly.
What exactly is a “pointer” in WebAssembly?
A 32-bit unsigned integer that is a byte offset into the module’s linear memory buffer — index 0 is the
first byte of that ArrayBuffer. It is not a machine address, and it is meaningful only relative to one
instance’s memory. That is why every bounds check is just offset < memory.byteLength.
Why does growing memory break my typed-array views?
memory.grow may need a larger contiguous region, so the engine can allocate a new backing buffer and
detach the old one. Any Uint8Array you built over the previous memory.buffer now has a detached buffer
and reads as zero-length. Always re-create views from instance.exports.memory.buffer after any call that
might grow memory — see why memory.grow invalidates pointers.
Do I need SharedArrayBuffer to use WebAssembly?
No. Single-threaded Wasm works with an ordinary, non-shared memory and needs no special headers. You only
need SharedArrayBuffer (and COOP/COEP) when you want true shared-memory threads across Web Workers.
How do I free memory I allocated for a string or buffer?
Whoever allocated it must free it. If your module exports an allocator, call its free(ptr, len) after the
data is no longer needed — typically in a finally block. wasm-bindgen inserts these frees for you in its
generated glue; with hand-written ABI you own the bookkeeping.
Related
- Stack vs heap execution model — how the value stack and
linear memoryheap interact. - Wasm instantiation lifecycle — how the
import objectis bound at instantiation time. - Rust to Wasm compilation guide — the toolchain that emits
wasm-bindgenglue. - ESM bindings & module generation — packaging the generated wrapper for your app.
← Back to all topics