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 memory from 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-bindgen and understand the JavaScript it emits, so you can debug and optimize it.
  • Share a single memory between the main thread and Web Workers using SharedArrayBuffer and coordinate with Atomics — 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 behaviourmalloc, free, bump allocators, and why memory.grow invalidates every existing view and pointer.
The JS–Wasm interop boundary JavaScript host on the left and a WebAssembly instance on the right, both reading and writing a single shared linear memory ArrayBuffer in the centre. Function calls pass only integers; bytes pass through memory. JavaScript host Uint8Array view TextEncoder / Decoder import object DOM · fetch · Workers linear memory one ArrayBuffer stack heap (malloc / free) data & globals Wasm instance exported functions i32 / i64 / f64 only load / store pure computation call: integer args only

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 AtomicsAtomics.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 memory and 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:


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.


← Back to all topics