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
.wasmfile 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:
- Fetch — download the
.wasmbytes (often gzip-compressed). - Compile —
WebAssembly.compile()or streaminginstantiateStreaming()validates and JITs the module. - Instantiate — allocate linear memory, wire up imports (functions the module needs from JS), and receive exports (functions and memory the module exposes).
- 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:
- Install the
wasm32-unknown-unknowntarget withrustup. - Add
wasm-bindgenandwasm-packto your crate. - Annotate exported functions with
#[wasm_bindgen]. - Run
wasm-pack build --target webto emit.wasmplus 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
.wasmmodule. - 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
- Game loop and frame timing explained — pairing WASM simulation with a JavaScript render loop
- Core Web Vitals explained — keep WASM bundles from hurting LCP and INP
- SSR vs CSR vs SSG explained — when to server-render the shell and hydrate WASM client-side
- Service workers and PWA explained — cache compiled WASM for offline and repeat-visit speed