Js Interop
JavaScript and WebAssembly interop patterns including memory sharing, type marshaling, and binding generation
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 linesJavaScript 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:
- Linear memory — a shared
ArrayBufferthat both JS and Wasm can read/write - Tables — for passing function references
- Binding generators — tools like
wasm-bindgenor 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
instantiateStreamingoverinstantiate— streaming compilation begins while the response is still downloading, reducing load time. - Use
TextEncoder/TextDecoderfor 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
ArrayBufferaftermemory.grow()— growing memory detaches the old buffer. All existingUint8Array,Float64Array, etc. views become zero-length and useless. Always re-derive views frommemory.bufferafter any potential growth. - Endianness assumptions — Wasm uses little-endian byte order. If you manually pack multi-byte values in JS, use
DataViewwith explicit little-endian to match. - Not freeing allocated memory — if Wasm allocates memory (via an exported
alloc), JS must call a correspondingdeallocwhen done, or memory leaks indefinitely. - Passing BigInt for i64 — JavaScript represents Wasm
i64values asBigInt. Passing a regularnumberwhereBigIntis expected (or vice versa) throws aTypeError. - 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 memory —
SharedArrayBuffer(required forshared: truememory) needsCross-Origin-Opener-Policy: same-originandCross-Origin-Embedder-Policy: require-corpheaders.
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 aftermemory.grow()reads from a detached buffer, producing silent data corruption. -
Not freeing allocated memory — if Wasm exposes an
allocfunction, JavaScript must call a correspondingdeallocwhen done; otherwise memory leaks grow the linear memory indefinitely. -
Passing
numberwhereBigIntis expected for i64 — JavaScript represents Wasmi64values asBigInt; mixing types causesTypeErrorat 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
Related Skills
Assemblyscript
Writing WebAssembly modules using AssemblyScript, a TypeScript-like language that compiles to Wasm
Performance
Optimizing WebAssembly performance including binary size, execution speed, memory usage, and profiling techniques
Rust WASM
Compiling Rust to WebAssembly using wasm-pack, wasm-bindgen, and the Rust Wasm ecosystem
Wasi
WebAssembly System Interface (WASI) for portable system-level access including filesystem, networking, and clocks
WASM Basics
WebAssembly fundamentals including module structure, types, memory model, and binary/text formats
WASM in Browser
Using WebAssembly in the browser for canvas rendering, audio processing, Web Workers, and DOM integration