Skip to main content
Technology & EngineeringWebassembly276 lines

Js Interop

JavaScript and WebAssembly interop patterns including memory sharing, type marshaling, and binding generation

Quick Summary18 lines
You are an expert in JavaScript and WebAssembly interoperability for building WebAssembly applications.

## Key Points

1. **Linear memory** — a shared `ArrayBuffer` that both JS and Wasm can read/write
2. **Tables** — for passing function references
3. **Binding generators** — tools like `wasm-bindgen` or Emscripten that generate glue code
- **Minimize boundary crossings** — each JS-to-Wasm or Wasm-to-JS call has overhead (roughly 10-50ns). Batch operations: pass a buffer of commands rather than calling per-item.
- **Reuse typed array views carefully** — create views like `new Uint8Array(memory.buffer)` after ensuring memory has not grown. Cache them only within a synchronous block.
- **Use binding generators for complex interfaces** — manual pointer arithmetic is error-prone. `wasm-bindgen` (Rust), Emscripten (C/C++), or AssemblyScript's ESM bindings automate marshaling.
- **Prefer `instantiateStreaming` over `instantiate`** — streaming compilation begins while the response is still downloading, reducing load time.
- **Use `TextEncoder`/`TextDecoder` for strings** — they handle UTF-8 encoding correctly and are highly optimized in modern engines.
- **Profile the boundary** — use browser DevTools performance profiling to identify if interop overhead is a bottleneck before optimizing.
- **Endianness assumptions** — Wasm uses little-endian byte order. If you manually pack multi-byte values in JS, use `DataView` with explicit little-endian to match.
- **Not freeing allocated memory** — if Wasm allocates memory (via an exported `alloc`), JS must call a corresponding `dealloc` when done, or memory leaks indefinitely.
- **Passing BigInt for i64** — JavaScript represents Wasm `i64` values as `BigInt`. Passing a regular `number` where `BigInt` is expected (or vice versa) throws a `TypeError`.
skilldb get webassembly-skills/Js InteropFull skill: 276 lines
Paste into your CLAUDE.md or agent config

JavaScript Interop — WebAssembly

You are an expert in JavaScript and WebAssembly interoperability for building WebAssembly applications.

Overview

JavaScript interop is the mechanism by which Wasm modules communicate with their JavaScript host. Since Wasm only natively supports numeric types (i32, i64, f32, f64), all complex data (strings, objects, arrays) must be marshaled through linear memory or through higher-level binding layers. Understanding this boundary is critical for both correctness and performance.

Core Concepts

The JS-Wasm Boundary

Wasm functions can only accept and return numeric types directly. Everything else must be passed through:

  1. Linear memory — a shared ArrayBuffer that both JS and Wasm can read/write
  2. Tables — for passing function references
  3. Binding generators — tools like wasm-bindgen or Emscripten that generate glue code

Import/Export Model

Wasm modules declare imports (functions they need from the host) and exports (functions they provide to the host). The host provides imports at instantiation time via the importObject.

Reference Types Proposal

The externref and funcref types allow Wasm to hold opaque references to JavaScript objects without copying them through memory. This significantly simplifies interop for handle-based APIs.

Implementation Patterns

Basic Function Calls

const { instance } = await WebAssembly.instantiateStreaming(
  fetch("math.wasm"),
  {}
);

// Call exported Wasm function
const result = instance.exports.add(10, 20);
console.log(result); // 30

Passing Strings: JS to Wasm

const { instance } = await WebAssembly.instantiateStreaming(
  fetch("app.wasm"),
  {}
);

const memory = instance.exports.memory;
const alloc = instance.exports.alloc;   // Wasm-side allocator
const process_string = instance.exports.process_string;

function writeString(str) {
  const encoder = new TextEncoder();
  const encoded = encoder.encode(str);
  // Allocate space in Wasm memory
  const ptr = alloc(encoded.length + 1); // +1 for null terminator
  const view = new Uint8Array(memory.buffer, ptr, encoded.length + 1);
  view.set(encoded);
  view[encoded.length] = 0; // null terminator
  return { ptr, len: encoded.length };
}

function readString(ptr, len) {
  const view = new Uint8Array(memory.buffer, ptr, len);
  return new TextDecoder().decode(view);
}

const { ptr, len } = writeString("Hello, Wasm!");
const resultPtr = process_string(ptr, len);
const output = readString(resultPtr, 32); // read up to 32 bytes

Passing Typed Arrays

const memory = instance.exports.memory;
const processArray = instance.exports.process_array;
const alloc = instance.exports.alloc;

// Write a Float64Array into Wasm memory
function sendFloat64Array(data) {
  const byteLength = data.length * 8; // 8 bytes per f64
  const ptr = alloc(byteLength);
  const view = new Float64Array(memory.buffer, ptr, data.length);
  view.set(data);
  return { ptr, len: data.length };
}

const input = new Float64Array([1.5, 2.7, 3.14, 4.0]);
const { ptr, len } = sendFloat64Array(input);
const sum = processArray(ptr, len);
console.log(sum); // 11.34

Calling JavaScript from Wasm

const importObject = {
  env: {
    // Wasm can call these imported functions
    js_log(ptr, len) {
      const view = new Uint8Array(memory.buffer, ptr, len);
      console.log(new TextDecoder().decode(view));
    },
    js_now() {
      return performance.now();
    },
    js_random() {
      return Math.random();
    },
  },
};

const { instance } = await WebAssembly.instantiateStreaming(
  fetch("app.wasm"),
  importObject
);
const memory = instance.exports.memory;

// Wasm code can now call env.js_log, env.js_now, env.js_random
instance.exports.run();

Shared Memory for Web Workers

// Main thread
const memory = new WebAssembly.Memory({
  initial: 10,
  maximum: 100,
  shared: true, // requires cross-origin isolation headers
});

const worker = new Worker("worker.js");
worker.postMessage({ memory });

// Both main thread and worker can instantiate Wasm with the same memory
const { instance } = await WebAssembly.instantiateStreaming(
  fetch("app.wasm"),
  { env: { memory } }
);

// Worker
self.onmessage = async (e) => {
  const { memory } = e.data;
  const { instance } = await WebAssembly.instantiateStreaming(
    fetch("app.wasm"),
    { env: { memory } }
  );
  // Both instances share the same linear memory
  instance.exports.worker_entry();
};

Callback Pattern with Function Tables

const table = new WebAssembly.Table({ initial: 4, element: "anyfunc" });

const importObject = {
  env: {
    table,
    call_callback(index, arg) {
      // Wasm calls this to invoke a JS-registered callback
      const fn = table.get(index);
      return fn(arg);
    },
  },
};

const { instance } = await WebAssembly.instantiateStreaming(
  fetch("app.wasm"),
  importObject
);

// Register a JS function in the table for Wasm to call indirectly
table.set(0, instance.exports.double);
table.set(1, instance.exports.square);

// Now Wasm can call_callback(0, 5) to get double(5) = 10
// or call_callback(1, 5) to get square(5) = 25

Using externref (Reference Types)

// With reference types enabled, Wasm can hold opaque JS references
const importObject = {
  env: {
    create_element(tag) {
      // tag is passed via some mechanism (e.g., integer ID mapped to string)
      return document.createElement("div");
    },
    set_text(elem, ptr, len) {
      // elem is an externref — an opaque JS object handle
      const text = readString(ptr, len);
      elem.textContent = text;
    },
    append_to_body(elem) {
      document.body.appendChild(elem);
    },
  },
};

Async Interop with Promises

// Wasm cannot natively await promises, so the pattern is:
// 1. JS calls Wasm to start work
// 2. Wasm calls back to JS for async operations
// 3. JS resolves the async operation and calls Wasm to continue

const importObject = {
  env: {
    async_fetch(urlPtr, urlLen, callbackId) {
      const url = readString(urlPtr, urlLen);
      fetch(url)
        .then((r) => r.arrayBuffer())
        .then((buf) => {
          const ptr = writeBuffer(buf);
          instance.exports.on_fetch_complete(callbackId, ptr, buf.byteLength);
        })
        .catch(() => {
          instance.exports.on_fetch_error(callbackId);
        });
    },
  },
};

Best Practices

  • Minimize boundary crossings — each JS-to-Wasm or Wasm-to-JS call has overhead (roughly 10-50ns). Batch operations: pass a buffer of commands rather than calling per-item.
  • Reuse typed array views carefully — create views like new Uint8Array(memory.buffer) after ensuring memory has not grown. Cache them only within a synchronous block.
  • Use binding generators for complex interfaces — manual pointer arithmetic is error-prone. wasm-bindgen (Rust), Emscripten (C/C++), or AssemblyScript's ESM bindings automate marshaling.
  • Prefer instantiateStreaming over instantiate — streaming compilation begins while the response is still downloading, reducing load time.
  • Use TextEncoder/TextDecoder for strings — they handle UTF-8 encoding correctly and are highly optimized in modern engines.
  • Profile the boundary — use browser DevTools performance profiling to identify if interop overhead is a bottleneck before optimizing.

Common Pitfalls

  • Stale ArrayBuffer after memory.grow() — growing memory detaches the old buffer. All existing Uint8Array, Float64Array, etc. views become zero-length and useless. Always re-derive views from memory.buffer after any potential growth.
  • Endianness assumptions — Wasm uses little-endian byte order. If you manually pack multi-byte values in JS, use DataView with explicit little-endian to match.
  • Not freeing allocated memory — if Wasm allocates memory (via an exported alloc), JS must call a corresponding dealloc when done, or memory leaks indefinitely.
  • Passing BigInt for i64 — JavaScript represents Wasm i64 values as BigInt. Passing a regular number where BigInt is expected (or vice versa) throws a TypeError.
  • Assuming synchronous fetch in Wasm — Wasm has no async/await. Fetching data must go through JS callbacks or use WASI for synchronous I/O.
  • Forgetting CORS/COEP headers for shared memorySharedArrayBuffer (required for shared: true memory) needs Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp headers.

Core Philosophy

The JS-Wasm boundary is the most performance-critical interface in your application. Every crossing costs 10-50 nanoseconds, which is negligible for a single call but devastating when multiplied by millions of iterations. Design your interop layer around batch operations: pass buffers of data rather than individual values, let Wasm process entire arrays in a single call, and minimize the number of round-trips between the two worlds.

Wasm's type system is deliberately minimal — only numbers cross the boundary natively. Everything else (strings, objects, arrays) must be marshaled through linear memory or handled by binding generators. Accept this constraint rather than fighting it. For complex interfaces, use tools like wasm-bindgen, Emscripten, or AssemblyScript's ESM bindings to generate the glue code. Hand-writing pointer arithmetic for string marshaling is educational but production-risky.

Linear memory is a shared resource that requires coordination. When Wasm grows memory, all existing TypedArray views over memory.buffer are invalidated — they become zero-length and silently return undefined values. This is the single most common source of interop bugs. Always re-derive views from memory.buffer after any operation that might trigger memory growth, and cache views only within synchronous blocks where growth cannot occur.

Anti-Patterns

  • Calling Wasm per element in a loop — making 10,000 individual boundary crossings instead of passing a buffer and processing in batch wastes the performance advantage Wasm provides.

  • Caching TypedArray views across potential memory growth — holding a reference to new Uint8Array(memory.buffer) and using it after memory.grow() reads from a detached buffer, producing silent data corruption.

  • Not freeing allocated memory — if Wasm exposes an alloc function, JavaScript must call a corresponding dealloc when done; otherwise memory leaks grow the linear memory indefinitely.

  • Passing number where BigInt is expected for i64 — JavaScript represents Wasm i64 values as BigInt; mixing types causes TypeError at the boundary.

  • Hand-writing string marshaling for complex interfaces — manual UTF-8 encoding, pointer management, and null terminator handling is error-prone; use binding generators for any interface beyond trivial numeric functions.

Install this skill directly: skilldb add webassembly-skills

Get CLI access →