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.


Ownership and lifetimes across the boundary

Nothing in WebAssembly tracks who owns a piece of memory. A pointer is an integer; the module has no way to know whether JavaScript still holds a view over the bytes it describes, and JavaScript has no way to know whether the module has freed them. Every working integration therefore rests on an ownership convention that the two sides agree on and that neither can enforce. Writing that convention down — in the type definitions, in a comment above the export, in the test that exercises it — is the difference between an interface that survives a refactor and one that produces intermittent corruption six months later.

Three rules cover almost every case. First, whoever allocates, frees: if JavaScript called an exported alloc, JavaScript calls the matching free, ideally in a finally block so an exception in between cannot leak. Second, a pointer returned from the module is on loan: read what you need from it immediately, and do not stash it in application state, because the next call into the module may reuse or release that region. Third, a view is not a value: a Uint8Array over linear memory aliases live bytes, so copying out of it is the only way to get data whose lifetime you control.

The failure modes follow directly. Holding a pointer across a call that allocates gives you a use-after-free that the sandbox cannot detect — the memory is still perfectly valid, it simply contains something else now. Holding a view across a call that grows memory gives you a detached array whose length silently becomes zero. Freeing on both sides gives you a double free, which in a bump allocator does nothing and in a real allocator corrupts the free list. None of these produce a trap at the moment of the mistake, which is exactly why the convention has to be explicit rather than inferred.

The practical discipline is to keep the window in which a raw pointer is meaningful as small as possible. Acquire, use, release inside a single function where a reader can see all three steps at once. When a lifetime genuinely has to span calls — a long-lived buffer, a parser context, a decoder instance — give it a handle rather than a raw pointer: an opaque integer the module maps to its own table, so a stale handle can be rejected instead of silently addressing the wrong bytes.

The conventions that keep the boundary correct Allocator frees; a returned pointer is borrowed, not owned; a view aliases live memory rather than holding a value. Each rule maps to a specific silent failure — use-after-free, a detached view, or a double free — none of which traps at the point of the mistake. whoever allocates, frees alloc in JS → free in JS, in a finally block prevents: double free, or a leak that only shows under load neither of which traps a returned pointer is on loan read it now; never store it in application state prevents: use-after-free with valid-looking memory the bytes are real — they are just someone else's now a view is not a value copy out if the data must outlive the call prevents: a detached array of length zero after any call that may grow memory For lifetimes that must span calls, hand out an opaque handle instead of a raw pointer — a stale handle can be rejected; a stale pointer cannot.

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.


Measuring the boundary rather than guessing at it

Reasoning about crossing costs in the abstract is unreliable, because the numbers move with the engine, the argument types, and whether the call is monomorphic enough for the JIT to specialise it. The measurement that actually settles a design question is a comparison between two versions of your own interface: the same work, once with N calls and once with one. If the batched version is not meaningfully faster, the boundary was never your bottleneck and you can keep the ergonomic API.

A workable harness is small. Instantiate once, outside the timer. Prepare the input once. Run a warmup until the per-iteration time stops falling, then time a fixed number of iterations of each variant, interleaved rather than in blocks so thermal drift affects both equally. Report the median and the 95th percentile, and record the engine version alongside them, because a number without that context cannot be compared against anything later.

function timed(label, fn, iterations = 200) {
  for (let i = 0; i < 50; i++) fn();          // warmup — discard
  const samples = [];
  for (let i = 0; i < iterations; i++) {
    const t0 = performance.now();
    fn();
    samples.push(performance.now() - t0);
  }
  samples.sort((a, b) => a - b);
  const p = (q) => samples[Math.floor(samples.length * q)].toFixed(3);
  console.log(`${label}: median ${p(0.5)} ms · p95 ${p(0.95)} ms`);
}

timed("per element", () => { for (let i = 0; i < n; i++) exports.scale_one(i, 1.5); });
timed("batched",     () => exports.scale_all(ptr, n, 1.5));

What the two lines tell you is more useful than any absolute figure. A large ratio says the interface shape is the problem and no amount of optimisation inside the module will fix it. A small ratio says the crossing is already cheap relative to the work, and effort belongs in the algorithm instead. That question — is the boundary or the body dominating? — is the one worth answering before any tuning work starts, and it takes about ten minutes to answer for real.

What the ratio tells you to do next If batching is dramatically faster, the interface shape dominates and the fix is redesign. If the two are close, the crossing is not the bottleneck and the algorithm inside the module is where the time is. batched is far faster the interface shape dominates optimising the module body changes nothing fix: redesign the API to cross less often the two are close the crossing is not your bottleneck keep the ergonomic interface fix: profile inside the module instead Both outcomes are useful; the expensive mistake is optimising for months without knowing which one you are in.

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.


Designing an interface you will not regret

Everything above describes mechanisms; this section is about the one decision that determines how often you will have to think about them. An interface designed around the boundary makes the mechanisms mostly invisible, while one designed around convenience puts them in your way daily.

Most of the difficulties above are consequences of one decision made early: what the exported functions look like. An interface designed around the boundary rather than around convenience avoids whole categories of problem, and it costs nothing to get right at the start.

Four properties make the difference. Coarse granularity — each exported function should do a meaningful amount of work, because the crossing cost is fixed per call and the only lever you control is how often it is paid. Explicit lengths — anything variable-sized crosses as a pointer and a length, in bytes, always; a convention that omits the length forces the other side to scan, and a convention that counts characters instead of bytes breaks the first time a non-ASCII string appears. Stable handles rather than raw pointers for anything whose lifetime spans calls, so that a stale reference is a rejected handle instead of a silent read of reallocated memory. And numeric identifiers rather than strings wherever an enumeration will do, because an integer needs no encoding, no allocation, and no free.

Each of the four is cheap to apply at design time and expensive to retrofit, because by the time the cost shows up there are callers depending on the shape you chose. Coarse granularity in particular is difficult to add later: an API built around per-item calls cannot be batched without changing every call site, whereas a batched API can always expose a convenience wrapper for the one place that wants per-item semantics. Design for the expensive direction first, and let convenience be a thin layer built on top of it rather than the foundation everything else has to work around.

Applied together these tend to produce an interface with a handful of exports rather than dozens: an initialiser that sets up state and returns a handle, one or two functions that do the real work over buffers you have already filled, and a disposer. That shape is easy to test, easy to type, cheap to call, and — not coincidentally — close to what a well-designed native library would expose. The temptation to mirror an object-oriented API method-for-method across the boundary is what produces the interfaces that later need rewriting.

There is one more property worth insisting on: the interface should be testable without a browser. If every export can be exercised from a script that instantiates the module, writes bytes into memory, calls a function and checks the result, then the boundary has a regression test that runs in seconds on every commit. Interfaces that require a canvas, a DOM node, or a user gesture to exercise end up untested, and an untested boundary is where the ownership rules quietly stop being followed.

It is worth writing the interface down before writing either side of it. A short list of exports with their signatures, ownership rules, and length conventions is a document both implementations can be checked against, and it is the artifact that makes a boundary bug a discrepancy rather than a mystery.

Explore this area

This area is organized into seven 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