JavaScript Interop: Wasm in the Browser
This chapter loads a .wasm module in the browser, calls exported functions from JS, and calls JS back from Wasm.
Instantiating a Module
Two APIs for loading Wasm: WebAssembly.instantiate and WebAssembly.instantiateStreaming. Prefer streaming; it starts compiling while the file is still downloading.
const imports = {
env: {
host_log: (ptr, len) => { /* ... */ },
},
};
const { instance, module } = await WebAssembly.instantiateStreaming(
fetch("counter.wasm"),
imports,
);
After instantiation:
instance.exportsis an object of exported functions, memories, and globals.moduleis the compiled module (cache-friendly; you can instantiate it multiple times).
Calling Exported Functions
Exported functions are plain JS functions on instance.exports.
const three = instance.exports.add(1, 2);
console.log(three); // 3
Arguments and return values are converted automatically between i32/i64/f32/f64 and JS numbers. i64 becomes BigInt in JS.
const result = instance.exports.sum64(1n, 2n); // BigInt args for i64
console.log(result); // 3n
Accessing Memory
Wasm's linear memory is exposed as instance.exports.memory (assuming you exported it).
const memory = instance.exports.memory;
const view = new Uint8Array(memory.buffer);
view[0] = 42;
console.log(view[0]); // 42
Views are snapshots of the underlying ArrayBuffer. Re-create them after any call that could grow memory:
instance.exports.do_something();
const freshView = new Uint8Array(instance.exports.memory.buffer);
Importing JS Functions
The imports object maps module name and function name to actual JS callables.
const imports = {
env: {
host_log: (ptr, len) => {
const view = new Uint8Array(instance.exports.memory.buffer);
const bytes = view.slice(ptr, ptr + len);
console.log(new TextDecoder().decode(bytes));
},
current_time: () => BigInt(Date.now()),
},
};
On the Rust side:
extern "C" {
fn host_log(ptr: *const u8, len: usize);
fn current_time() -> u64;
}
When Wasm calls host_log, it's calling your JS function. When you call instance.exports.whatever, it's calling Wasm.
The Circular Reference Trap
The imports object needs instance, but instance is created after instantiation. Two ways around it.
Mutable Holder
let instance;
const imports = {
env: {
host_log: (ptr, len) => {
const view = new Uint8Array(instance.exports.memory.buffer);
/* ... */
},
},
};
({ instance } = await WebAssembly.instantiateStreaming(fetch("counter.wasm"), imports));
Works, mildly ugly.
Pass Memory Directly
Instead of reaching through instance, import memory too:
const memory = new WebAssembly.Memory({ initial: 10 });
const imports = {
env: {
memory,
host_log: (ptr, len) => {
const view = new Uint8Array(memory.buffer);
/* ... */
},
},
};
Rust needs to import, not export, memory. This pattern is rare; the mutable holder is more common.
Strings: The Canonical Example
Passing a string from JS into Wasm and getting one back.
Rust side:
#[no_mangle]
pub extern "C" fn alloc(size: usize) -> *mut u8 {
let mut buf = Vec::with_capacity(size);
let ptr = buf.as_mut_ptr();
std::mem::forget(buf);
ptr
}
#[no_mangle]
pub unsafe extern "C" fn dealloc(ptr: *mut u8, size: usize) {
let _ = Vec::from_raw_parts(ptr, 0, size);
}
#[no_mangle]
pub unsafe extern "C" fn greet(
name_ptr: *const u8,
name_len: usize,
out_ptr: *mut u8,
out_cap: usize,
) -> usize {
let name = std::slice::from_raw_parts(name_ptr, name_len);
let name = std::str::from_utf8_unchecked(name);
let greeting = format!("Hello, {}!", name);
let bytes = greeting.as_bytes();
let copy_len = bytes.len().min(out_cap);
std::ptr::copy_nonoverlapping(bytes.as_ptr(), out_ptr, copy_len);
bytes.len() // may be > out_cap; host decides what to do
}
JS side:
async function greet(instance, name) {
const encoder = new TextEncoder();
const nameBytes = encoder.encode(name);
// Allocate space for the input
const namePtr = instance.exports.alloc(nameBytes.length);
// Write it
let view = new Uint8Array(instance.exports.memory.buffer);
view.set(nameBytes, namePtr);
// Allocate output buffer
const outCap = 256;
const outPtr = instance.exports.alloc(outCap);
// Call
const outLen = instance.exports.greet(namePtr, nameBytes.length, outPtr, outCap);
// Read result (re-view in case memory grew)
view = new Uint8Array(instance.exports.memory.buffer);
const greetingBytes = view.slice(outPtr, outPtr + Math.min(outLen, outCap));
const greeting = new TextDecoder().decode(greetingBytes);
// Free
instance.exports.dealloc(namePtr, nameBytes.length);
instance.exports.dealloc(outPtr, outCap);
return greeting;
}
console.log(await greet(instance, "Ada")); // "Hello, Ada!"
This is tedious. It's also exactly what wasm-bindgen automates. Chapter 6 replaces all of it with #[wasm_bindgen] fn greet(name: &str) -> String.
Error Handling
Wasm doesn't have exceptions (yet; a proposal exists). A function that "fails" needs to return an error code or write an error to the host log.
#[no_mangle]
pub extern "C" fn divide(a: i32, b: i32) -> i32 {
if b == 0 {
-1 // sentinel; caller knows to check
} else {
a / b
}
}
A sturdier pattern:
// Return success flag and write result to a pointer
#[no_mangle]
pub extern "C" fn divide_checked(a: i32, b: i32, out: *mut i32) -> i32 {
if b == 0 { return 0; }
unsafe { *out = a / b; }
1
}
wasm-bindgen maps Rust's Result to JS exceptions. The Exception Handling proposal makes this native, but adoption is still rolling out.
Loading from a Bundler
Webpack, Vite, Parcel: all of them treat .wasm specially.
Direct fetch (works everywhere)
const response = await fetch(new URL("./counter.wasm", import.meta.url));
const { instance } = await WebAssembly.instantiateStreaming(response, imports);
Vite with the Wasm plugin
npm install vite-plugin-wasm --save-dev
// vite.config.js
import wasm from "vite-plugin-wasm";
export default { plugins: [wasm()] };
Then import directly:
import init from "./counter.wasm?init";
const instance = await init(imports);
Webpack 5
Webpack 5 has first-class async WebAssembly support. Chapter 7 walks through a full setup.
Start-Up Cost
Instantiating a Wasm module has overhead: fetch, validate, compile, link. A few milliseconds on a warm machine, more on mobile. Strategies:
- Cache the compiled module.
WebAssembly.compileStreamingonce, instantiate many times. - Lazy-load on demand. Don't instantiate on page load if the user might not need Wasm.
- Warm up on idle.
requestIdleCallbackto precompile while the main thread is idle.
For most apps, default instantiate time is fine. Worry about it if you're sub-100ms-to-interactive.
Common Pitfalls
Re-using a stale view after memory.grow. Detached buffer; reads are zero. Always re-view.
Using instantiate when instantiateStreaming works. Streaming is strictly faster.
Importing a function that takes a string and expecting Wasm to handle it. Wasm can't. Pass ptr/len.
Forgetting to export memory. Host can't read or write. Add (export "memory" (memory 0)) on the Rust side.
Missing imports at instantiation. LinkError: Import #0 module="env" function="host_log" error: function import requires a callable. You forgot a function in the imports object.
Next Steps
Continue to 06-wasm-bindgen.md for ergonomic Rust–JS interop.