Guide

WebAssembly (WASM) explained

JavaScript can do almost anything in a modern browser, but it was never designed for heavy numeric workloads — physics simulations, video codecs, image filters, cryptography at scale, or game engines pushing millions of triangles per second. WebAssembly (abbreviated WASM) is a portable, low-level binary instruction format that runs inside the same sandbox as your JavaScript, at speeds close to native machine code. Figma, Google Earth, AutoCAD Web, and countless browser games compile performance-critical code to WASM and call it from JavaScript. This guide explains what WASM actually is, how browsers execute it, the memory and interop model, how to compile from Rust, and when reaching for WASM is worth the engineering cost.

What WebAssembly is (and is not)

WASM is not a programming language — it is a compilation target, like machine code for a virtual CPU. You write Rust, C, C++, Zig, or AssemblyScript; a toolchain lowers that source to a .wasm binary containing typed functions, a linear memory region, and import/export tables. Browsers ship a WASM engine (V8, SpiderMonkey, JavaScriptCore) that JIT-compiles those instructions to native CPU code at load time.

WASM is also not a replacement for JavaScript. The web platform still needs JS for DOM access, networking, and glue logic. Typical architecture: JavaScript owns the UI and event loop; WASM owns the hot loop. The two cooperate through a narrow, explicitly declared boundary.

  • Portable — the same .wasm file runs in Chrome, Firefox, Safari, and Node.js with identical semantics.
  • Fast — no interpreter overhead; engines compile WASM to optimized machine code in one pass.
  • Compact — binary encoding is smaller than equivalent minified JS for numeric code.
  • Sandboxed — WASM cannot access the filesystem, network, or DOM unless the host explicitly imports those capabilities.

How WASM runs inside a browser

Loading a module follows a predictable pipeline:

  1. Fetch — download the .wasm bytes (often gzip-compressed).
  2. CompileWebAssembly.compile() or streaming instantiateStreaming() validates and JITs the module.
  3. Instantiate — allocate linear memory, wire up imports (functions the module needs from JS), and receive exports (functions and memory the module exposes).
  4. Call — JavaScript invokes exported functions; WASM may call back into imported JS shims.

A minimal loader in modern JavaScript looks like:

const response = await fetch('/engine.wasm');
const { instance } = await WebAssembly.instantiateStreaming(
  response,
  { env: { log: (n) => console.log(n) } }  // imports
);
instance.exports.run_simulation(16);           // call exported fn

Streaming compilation starts JIT work before the full file downloads, which matters on slow mobile connections. Pair WASM with a service worker cache so repeat visits skip the network entirely.

Linear memory and the data boundary

WASM modules see a single growable array of bytes called linear memory — essentially a giant ArrayBuffer indexed from 0 upward. There are no pointers into JavaScript objects; strings, structs, and arrays live as raw bytes inside this buffer. JavaScript reads them with typed arrays (Float64Array, Uint8Array) wrapped around memory.buffer.

This design is both a strength and a friction point:

  • Strength — predictable layout, no GC pauses inside WASM, easy to share with WebGL and WebGPU buffers.
  • Friction — crossing the boundary to pass a string requires copying bytes into linear memory and tracking offsets; high-frequency calls pay marshalling tax.

High-performance integrations batch work: pass one pointer and length, let WASM crunch thousands of elements, return a single result code. Avoid calling WASM once per pixel or per DOM event — that overhead erases the speed advantage. Tools like wasm-bindgen (Rust) and Emscripten (C/C++) generate glue code that hides most of this plumbing.

Compiling from Rust (the most popular path)

Rust has become the default language for new WASM projects because its ownership model eliminates use-after-free bugs without a garbage collector — critical in a memory-safe sandbox with no debugging symbols in production. The standard workflow:

  1. Install the wasm32-unknown-unknown target with rustup.
  2. Add wasm-bindgen and wasm-pack to your crate.
  3. Annotate exported functions with #[wasm_bindgen].
  4. Run wasm-pack build --target web to emit .wasm plus JS glue.

C and C++ developers typically use Emscripten, which translates POSIX-like APIs to browser shims and can port large legacy codebases (game engines, codecs) with less rewrite cost — at the price of larger binaries and Emscripten's own runtime layer.

Binary size matters on the web. A 5 MB WASM blob blocks first paint unless you lazy-load it after the shell renders. Use wasm-opt from Binaryen, strip debug symbols, and enable link-time optimization (-Oz). Monitor download size alongside Core Web Vitals — a fast simulation that delays LCP still hurts UX.

JavaScript interop: imports, exports, and SharedArrayBuffer

WASM modules declare an import table — functions they need from the host — and an export table — functions and memory they expose. Common imports include console.log shims, fetch wrappers, and canvas/WebGL bindings. The host must supply every import at instantiation or loading fails with a link error.

For multi-threaded WASM (parallel physics, ray tracing), browsers require cross-origin isolated pages so SharedArrayBuffer is available. That means setting COOP/COEP headers — the same requirement as high-resolution timers. Configure Content Security Policy carefully: script-src 'wasm-unsafe-eval' is needed in some setups, though modern sites often rely on strict CSP with nonce-based scripts alongside WASM modules loaded as application/wasm.

WebAssembly Interface Types (the Component Model, still maturing) aim to pass rich types (strings, records) across the boundary without manual byte copying. Until that ships everywhere, treat interop as a designed API surface, not an afterthought.

WASI: WebAssembly outside the browser

WebAssembly System Interface (WASI) standardizes how WASM modules access files, clocks, random numbers, and sockets in non-browser runtimes. Wasmtime, Wasmer, and Spin run the same .wasm artifact on servers, edge workers, and CI pipelines — useful for portable plugins, sandboxed user code execution, and polyglot microservices.

Cloudflare Workers, Fastly Compute, and Fermyon Spin compile services to WASM for cold-start latency measured in microseconds instead of hundreds of milliseconds for container spin-up. The trade-off: no full Linux syscall surface; you program against WASI capabilities the host grants.

Where WASM shines (and where it does not)

Good fits

  • Browser games and physics — deterministic simulation steps compiled from Rust/C++, paired with a JS game loop that handles input and rendering.
  • Image, video, and audio processing — filters, encoders, and DSP that would choke interpreted JS.
  • Cryptography and hashing at volume — verifying Merkle proofs, parsing blocks, or batch signature checks (though sensitive key material should stay out of WASM reachable from XSS).
  • CAD, GIS, and creative tools — porting existing C++ engines (Figma, AutoCAD Web, Google Earth).
  • Portable compute plugins — same module in browser and server via WASI.

Poor fits

  • DOM-heavy UI — React/Vue/Svelte in JS are faster to build and plenty fast for forms and dashboards.
  • Infrequent, tiny computations — marshalling cost exceeds savings for a single hash or JSON parse.
  • Rapid prototyping — compile cycles and debugging across two languages slow iteration unless the team already knows Rust.

Security model

WASM executes inside the same origin sandbox as JavaScript. A WASM module cannot read cookies, touch localStorage, or open sockets unless JavaScript imports those capabilities. Memory is bounds-checked — out-of-bounds access traps instead of corrupting the host heap.

WASM does not make unsafe languages safe: a C module with memory corruption bugs can still misbehave inside linear memory. Supply-chain risk matters too — only load WASM from trusted origins and pin Subresource Integrity hashes when served from CDNs. Treat WASM like any third-party script: audit what it imports and what data you pass across the boundary.

Key takeaways

  • WebAssembly is a binary compilation target, not a language — write in Rust/C++ and ship a .wasm module.
  • JavaScript owns the DOM and event loop; WASM owns compute-heavy hot paths called through a narrow import/export API.
  • Data lives in linear memory; batch work to minimize JS↔WASM boundary crossings.
  • wasm-pack + Rust is the most ergonomic browser path today; Emscripten ports legacy C++.
  • WASI extends WASM to servers and edge with sandboxed syscalls — same artifact, multiple hosts.
  • Measure download size and LCP alongside raw compute speed; lazy-load large modules after first paint.

Related reading