Deep dive
Decision table
| Situation | Choice | Why |
|---|---|---|
| Hot kernel in a web page | Rust/C++ to wasm32-unknown-unknown, one call per buffer | Predictable speed; boundary cost paid once |
| DOM, fetch, UI glue | JavaScript/TypeScript | Wasm has no DOM access; every call goes through JS |
| Untrusted user plugins on a server | Wasmtime with memory limits and epoch interruption | No ambient authority; runaway code is stoppable |
| Same validation logic in browser and backend | One Rust crate, native + wasm builds | No drift between implementations |
| Many short-lived requests (edge functions) | AOT-compiled module, pooling allocator | Instantiation avoids compiling and copying memory |
| Microcontroller or tiny host | Interpreter (wasmi, WAMR) | Small footprint, no JIT memory |
| Kotlin/Dart UI in the browser | Wasm GC targets | The engine's collector replaces a bundled one |
| Threads in the browser | Shared memory plus cross-origin isolation | SharedArrayBuffer needs COOP/COEP headers |
Code
Hand-emitted module (no libraries): one memory, sum(ptr, len) -> i32. It shows the whole binary format: header, sections, LEB128, structured control flow. Compiled with stable Rust and run in Node: sum(100,4) over bytes 1,2,3,250 returned 256, validate returned true.
fn leb(mut v: u32, out: &mut Vec<u8>) { // unsigned LEB128
loop {
let b = (v & 0x7f) as u8;
v >>= 7;
if v == 0 { out.push(b); return; }
out.push(b | 0x80);
}
}
fn section(id: u8, body: &[u8], out: &mut Vec<u8>) {
out.push(id);
leb(body.len() as u32, out);
out.extend_from_slice(body);
}
fn name(s: &str, out: &mut Vec<u8>) {
leb(s.len() as u32, out);
out.extend_from_slice(s.as_bytes());
}
fn module() -> Vec<u8> {
let mut m = b"\0asm\x01\0\0\0".to_vec(); // magic + version 1
section(1, &[1, 0x60, 2, 0x7f, 0x7f, 1, 0x7f], &mut m); // type: (i32,i32)->i32
section(3, &[1, 0], &mut m); // function 0 uses type 0
section(5, &[1, 0x00, 1], &mut m); // memory, min 1 page
let mut e = vec![2];
name("mem", &mut e); e.extend([2, 0]); // export kind 2 = memory
name("sum", &mut e); e.extend([0, 0]); // export kind 0 = function
section(7, &e, &mut m);
// locals: 0 = ptr, 1 = len, 2 = acc
let body: &[u8] = &[
1, 1, 0x7f, // one extra i32 local
0x02, 0x40, 0x03, 0x40, // block, loop
0x20, 1, 0x45, 0x0d, 1, // if len == 0 break
0x20, 2, 0x20, 0, 0x2d, 0, 0, 0x6a, 0x21, 2, // acc += load8_u(ptr)
0x20, 0, 0x41, 1, 0x6a, 0x21, 0, // ptr += 1
0x20, 1, 0x41, 1, 0x6b, 0x21, 1, // len -= 1
0x0c, 0, 0x0b, 0x0b, // continue; end loop; end block
0x20, 2, 0x0b, // return acc
];
let mut c = vec![1];
leb(body.len() as u32, &mut c);
c.extend_from_slice(body);
section(10, &c, &mut m);
m
}
fn main() {
let mut b = Vec::new();
leb(624485, &mut b);
assert_eq!(b, [0xe5, 0x8e, 0x26]);
let m = module();
std::fs::write("sum.wasm", &m).unwrap();
}
Host side (Node and browsers share this API); also run:
const { instance } = await WebAssembly.instantiate(bytes); // browsers: instantiateStreaming(fetch(url))
const { mem, sum } = instance.exports;
new Uint8Array(mem.buffer).set([1, 2, 3, 250], 100); // copy in, then ONE call
sum(100, 4); // 256
Guest-side pattern for real crates (the exports compile and pass asserts natively; build for Wasm with cargo build --release --target wasm32-unknown-unknown):
#[unsafe(no_mangle)]
pub extern "C" fn alloc(len: usize) -> *mut u8 {
let mut v = Vec::<u8>::with_capacity(len);
let p = v.as_mut_ptr();
std::mem::forget(v);
p
}
#[unsafe(no_mangle)]
pub unsafe extern "C" fn dealloc(ptr: *mut u8, cap: usize) {
unsafe { drop(Vec::from_raw_parts(ptr, 0, cap)) }
}
#[unsafe(no_mangle)]
pub unsafe extern "C" fn checksum(ptr: *const u8, len: usize) -> u32 {
let s = unsafe { std::slice::from_raw_parts(ptr, len) };
s.iter().fold(0u32, |a, &b| a.wrapping_mul(31).wrapping_add(b as u32))
}
Size-oriented release profile (Cargo.toml), then wasm-opt -Oz in.wasm -o out.wasm:
[profile.release]
opt-level = "s" # or "z"; measure, "3" is sometimes faster
lto = true
codegen-units = 1
panic = "abort"
strip = true
Complexity and constants
- Compile cost is roughly linear in module size (V8 Liftoff article); V8 reported 39.5 MB modules taking over 30 s to start on many machines (2018) before its baseline compiler.
- Steady state: native-class for numeric kernels, but SPEC CPU 2019: 45 % (Firefox) to 55 % (Chrome) slower on average, peak 2.08x and 2.5x. Earlier work reported about 10 % on small scientific kernels.
- An interpreter is 20x or more slower than compiled Wasm (V8's own measurement), so interpret only where JIT is impossible.
- Every JS-to-Wasm call crosses a boundary; only numbers pass directly, everything else is copied through linear memory.
- Memory only grows, never shrinks;
growdetaches existingArrayBufferviews.
Efficiency
- Cost: compiled Wasm ran SPEC CPU 45-55 % slower than native on average in browsers (Jangda et al., 2019; peaks 2.08x Firefox, 2.5x Chrome); small scientific kernels came within about 10 %. An interpreter is 20x or more slower than compiled code (V8 Liftoff post). Linear memory grows in 64 KiB pages. The bound is usually boundary traffic plus download and compile time, not the kernel.
- Levers:
- batch across the boundary — one call per buffer or frame instead of per element (no figure) — any JS↔Wasm or host↔guest loop
- SIMD (
-C target-feature=+simd128,-msimd128) — V8's CPU hand-tracking demo went from 14-15 to 38-40 FPS (V8 SIMD post) — data-parallel kernels; feature-detect and ship two builds - streaming + tiered compile (
instantiateStreaming, baseline tier then optimizing) — compile overlaps download (V8 Liftoff post) — large browser modules; serveapplication/wasm - compile once on the server (
wasmtime compile→.cwasm,Module::deserialize), copy-on-write memory init, pooling allocator — skips compilation and most instantiation work (Wasmtime docs) — many short-lived instances - ship less (
panic = "abort", LTO,opt-level = "s",wasm-opt, Brotli/gzip) — smaller download (no figure) — browser delivery
- Naive → standard → state of the art:
- naive · per-element calls across the boundary, interpreter or cold compile per request: 20x+ slower than compiled
- standard · batched calls, optimizing compile, guard-page bounds checks: order of 1.5x native (SPEC average above)
- state of the art · AOT-precompiled
.cwasm, CoW instantiation, SIMD kernels; epoch interruption instead of fuel for runaway control; plain JS or WebGPU when the work is DOM-bound or massively parallel
Implementation notes
- Pass strings and structs as (ptr, len) into memory you allocated through guest
alloc; decode withTextDecoder. Re-create typed-array views after anything that can grow memory. wasm32-unknown-unknownhas no OS; usewasm32-wasip1/wasm32-wasip2for WASI, andwasm-bindgenwhen you want generated JS glue.- Components describe interfaces in WIT and compose across languages; core modules are the right unit for a single hot kernel.
- Execution is mostly deterministic; NaN payloads, relaxed SIMD, threads and
memory.growfailure are not. Check before using it for replay or consensus.
Pitfalls
- Calling Wasm per element or per small string: boundary and copy cost exceeds the work.
- Holding a
Uint8Arrayacross amemory.grow(view length becomes 0). - Serving
.wasmwithoutapplication/wasm(streaming fails) or without COOP/COEP when using threads. - Assuming sandbox means no resource abuse: set memory, table and time limits.
- Expecting DOM, threads or filesystem to "just work"; each needs host support.
- Judging speed from a micro-benchmark that ignores download and tier-up.
Variants and alternatives
In production
Browsers run it in all major engines; Figma runs its editor on it. Edge and CDN: Fastly Compute and Cloudflare Workers. Proxies: Envoy loads Wasm extensions. Server-side: Wasmtime, Wasmer, WasmEdge and WAMR embed it for plugins and edge functions. Kotlin/Wasm and Flutter's Wasm build use Wasm GC.
Implementations
| name | lang | repo | install | verdict | licence | health | as_of | notes |
|---|---|---|---|---|---|---|---|---|
| Wasmtime | Rust | bytecodealliance/wasmtime | brew install wasmtime / cargo add wasmtime | — | — | — | — | Cranelift JIT/AOT, WASI, components, pooling |
| Wasmer | Rust | wasmerio/wasmer | brew install wasmer / cargo add wasmer | — | — | — | — | pluggable backends, embeds in many languages |
| wasm-bindgen | Rust | wasm-bindgen/wasm-bindgen | cargo add wasm-bindgen | — | — | — | — | JS/Rust glue generator |
| Binaryen (wasm-opt) | C++ | WebAssembly/binaryen | brew install binaryen | — | — | — | — | optimizer for size and speed |
| Emscripten | C++ | emscripten-core/emscripten | brew install emscripten | — | — | — | — | C/C++ toolchain for Wasm and JS |
Sources
- WebAssembly Community Group, WebAssembly Specification. spec
- A. Rossberg, Wasm 3.0 Completed, webassembly.org, 2025-09-17. news
- A. Haas et al., Bringing the Web up to Speed with WebAssembly, PLDI 2017. paper
- A. Jangda, B. Powers, E. Berger, A. Guha, Not So Fast: Analyzing the Performance of WebAssembly vs. Native Code, USENIX ATC 2019. paper · arXiv
- C. Backes, Liftoff: a new baseline compiler for WebAssembly in V8, 2018. post
- D. Gandluri, T. Lively, I. Stepanyan, Fast, parallel applications with WebAssembly SIMD, V8, 2020. post
- Bytecode Alliance, Wasmtime
ConfigAPI (memory_init_cow, guard size, fuel, epoch). docs - MDN, WebAssembly.instantiateStreaming() and Memory.grow(). streaming · grow
- WASI, wasi.dev; Component Model, component-model.bytecodealliance.org
- E. Wallace, WebAssembly cut Figma's load time by 3x, Figma blog, 2017. post
- Fastly, Compute. docs · Cloudflare, WebAssembly (Wasm). docs · Envoy, Wasm. docs