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.exports is an object of exported functions, memories, and globals.
  • module is 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.compileStreaming once, instantiate many times.
  • Lazy-load on demand. Don't instantiate on page load if the user might not need Wasm.
  • Warm up on idle. requestIdleCallback to 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.