WebAssembly Integration

TL;DR / WebAssembly (Wasm) runs compiled C/C++/Rust code in the browser at near-native speed, interoperating with JavaScript through a shared linear memory and imported/exported function interface.

How It Works

           ┌────────────────┐
           │ C / C++ / Rust │
           └────────────────┘
                    │
                    └┐
                     ↓
       ┌──────────────────────────┐
       │         Compiler         │
       │  (Emscripten/wasm-pack)  │
       └──────────────────────────┘
                     │
         ┌───────────└───────────┐
         │                       │
         ↓                       ↓
 ┌──────────────┐        ┌──────────────┐
 │ .wasm binary │        │ JS glue code │
 └──────────────┘        └──────────────┘
         │                       │
         └───────────┌───────────┘
                     │
                     ↓
       ┌──────────────────────────┐
       │       WebAssembly        │
       │      .instantiate()      │
       └──────────────────────────┘

         JS calls wasm exports
         wasm calls JS imports

Edit diagram

WebAssembly is a binary instruction format designed to be a compilation target for languages like C, C++, Rust, Go, and Zig. It runs in the same sandbox as JavaScript with the same security guarantees, but its static type system, compact binary encoding, and predictable execution model let browsers compile it to native machine code ahead of time, achieving performance within 10-20% of native execution for computation-heavy workloads.

Compilation Pipeline

The path from source code to running Wasm involves several stages. For C/C++, Emscripten is the standard toolchain: it compiles source to LLVM IR, then to WebAssembly, and generates JavaScript "glue code" that provides a runtime environment (file system emulation, memory management, standard library implementations). For Rust, wasm-pack wraps the wasm32-unknown-unknown target and generates npm-compatible packages with TypeScript bindings.

The output is a .wasm binary file and typically a JavaScript module that loads and instantiates it. The binary is compact — a simple image processing library might be 50-200KB, significantly smaller than an equivalent JavaScript implementation because the binary format omits parsing overhead.

Loading and Instantiation

The browser loads Wasm through the WebAssembly API. The recommended approach is streaming instantiation: WebAssembly.instantiateStreaming(fetch('module.wasm'), importObject). This compiles the module while it is still downloading — the browser does not wait for the entire file before starting compilation. The importObject provides functions that the Wasm module can call back into JavaScript.

Instantiation returns two things: a Module (the compiled code, cacheable and reusable) and an Instance (a running instance with its own memory and state). You can instantiate the same module multiple times with different import objects to create isolated instances.

The Import/Export Interface

Wasm and JavaScript communicate through a well-defined boundary. The Wasm module declares imports it needs (functions, memory, globals, tables) and exports it provides. Exports are typically functions: instance.exports.processImage(ptr, len) calls a function defined in C or Rust. Imports are JavaScript functions the Wasm code calls: env.console_log(ptr) might be a JS function that reads a string from Wasm memory and calls console.log.

This boundary only supports numeric types: i32, i64, f32, f64. You cannot pass a JavaScript object, string, or array directly to Wasm. Complex data must be serialized into the Wasm module's linear memory, and the Wasm function receives a pointer (an i32 offset into memory) and a length.

Linear Memory

Each Wasm instance has a linear memory — a contiguous, resizable ArrayBuffer accessible from both Wasm and JavaScript. JavaScript accesses it through instance.exports.memory.buffer, creating typed array views to read and write data.

This shared memory is the primary data exchange mechanism. To pass a string to Wasm: encode it as UTF-8, allocate space in Wasm memory (via an exported malloc-like function), write the bytes, and pass the pointer and length. To read a string back: get the pointer and length from the return value, create a Uint8Array view, and decode with TextDecoder.

Tools like wasm-bindgen (Rust) and Embind (C++) automate this marshaling, generating wrappers that handle string conversion and memory management automatically.

Performance Characteristics

Wasm excels at predictable, computation-heavy workloads: image/video processing, cryptography, compression, physics engines, audio synthesis, and numerical simulation. Its advantage comes from static types (no boxing/unboxing overhead), predictable memory layout (no garbage collector pauses), and ahead-of-time compilation to optimized machine code.

Wasm is not faster than JavaScript for all tasks. DOM manipulation requires calling back into JavaScript. Frequent cross-boundary calls have overhead from marshaling arguments. The sweet spot is large batches of computation with minimal boundary crossings.

Integration Patterns

Computation offload: The most common pattern. JavaScript handles UI and orchestration, calling into Wasm for expensive operations (e.g., wasm.compress(data) returning compressed bytes).

Full application in Wasm: Frameworks like Yew (Rust) and Blazor (.NET) compile entire applications to Wasm, with thin JavaScript layers for DOM access. This trades JavaScript's DOM ergonomics for language preference or code reuse.

Wasm in Workers: Combining Wasm with Web Workers gives you off-main-thread execution of native-speed code. The Wasm module is instantiated inside the worker, and results are transferred back via postMessage with transferable ArrayBuffers.

WASI (WebAssembly System Interface): A standard providing file system, network, and OS-like APIs to Wasm modules, enabling the same binary to run in browsers, Node.js, Deno, and standalone runtimes like Wasmtime.

Gotchas

  • No direct DOM access — Wasm cannot call document.querySelector or manipulate the DOM. Every DOM operation requires a round-trip through JavaScript imports, which negates performance gains for DOM-heavy workloads.
  • Memory is manually managed — Wasm linear memory has no garbage collector. Memory allocated inside Wasm (via malloc or Rust's allocator) must be explicitly freed. Leaks accumulate until the instance is destroyed.
  • Large .wasm files block initial load — while streaming compilation helps, a multi-megabyte Wasm file still takes time to download and compile. Use code splitting to load Wasm modules lazily, and consider caching compiled modules with IndexedDB via WebAssembly.Module.
  • Debugging is limited — browser DevTools support Wasm debugging with source maps (DWARF for C/C++, Rust source maps), but the experience is rougher than JavaScript debugging. Variable inspection and stepping through optimized code can be unreliable.
  • 64-bit integers require BigInt — Wasm i64 values map to JavaScript BigInt, not regular numbers. Passing regular numbers where BigInt is expected (or vice versa) throws TypeError. This is a common source of interop bugs.