WebAssembly
JavaScript is fast. Modern engines JIT it into machine code that, for most web work, you will never out-run by hand. But “most” is not “all.” Decode a video frame, resize a 40-megapixel photo, run a chess engine twelve plies deep, hash a password with a deliberately expensive KDF — and you hit a wall. The engine spends its time guessing what your dynamic types are, deoptimizing when it guesses wrong, and boxing numbers it can’t prove are integers.
WebAssembly is the escape hatch. It is a compact binary instruction format — a compilation target — that the browser runs in a sandbox, alongside your JavaScript, at speed close to native code. You do not write it by hand. You write C, Rust, C++, Go, or a JS-like language, and a compiler emits a .wasm file. Your page fetches that file, hands it to the engine, and calls into it like a very fast library.
This article is about the shape of that system: what a module is, how memory is shared, what it actually costs to pass a string across the line, and where Wasm earns its keep versus where plain JavaScript is still the right tool.
What Wasm actually is
Strip away the marketing and WebAssembly is three things at once.
It is a bytecode: a stack-based virtual instruction set. Instructions push and pop values on an operand stack — i32.add pops two 32-bit integers and pushes their sum. There are only four value types in the core spec — i32, i64, f32, f64 — plus references. No objects, no strings, no garbage collector in the base layer. That smallness is the point: it is trivial to validate and fast to compile.
It is a compile target. Nobody hand-writes the bytecode for production. A toolchain lowers a higher-level language to it, the same way a C compiler lowers to x86.
And it is a sandbox. A Wasm module cannot touch the DOM, read a file, or make a network call on its own. It can only compute, and reach the outside world through functions you explicitly hand it. Its memory is a single flat array it cannot grow past what you allow. A malicious .wasm is no more dangerous than a malicious .js — arguably less, because its capabilities are enumerated at the boundary.
That last box is the whole idea: after loading, Wasm and JS live in the same tab, on the same thread by default, and call each other directly. Wasm is not a plugin or a separate process. It is more machine code the same engine runs.
The four building blocks
The JavaScript API exposes Wasm through a handful of objects on the global WebAssembly namespace. Four of them carry the mental model.
exports are the functions you call.ArrayBuffer of raw bytes — the instance’s entire heap. JS can read and write it too.The split between Module and Instance matters for performance. Compiling bytecode to machine code is the expensive step; it happens once, produces a WebAssembly.Module, and that module is stateless. You can instantiate it many times — each WebAssembly.Instance gets its own fresh memory and globals — and you can postMessage a compiled module to a Web Worker without recompiling. Think class versus object.
Memory is where the interesting work happens, so it gets its own section. Table you can mostly ignore until you are doing dynamic dispatch or C++ virtual calls — the compiler manages it for you.
Linear memory: one ArrayBuffer, two readers
A Wasm instance does not have a JavaScript-style heap of objects. It has linear memory: a single contiguous block of bytes, addressed from zero, that it uses for everything — its stack, its heap, its globals. In the JavaScript world that block is an ArrayBuffer, reachable as instance.exports.memory.buffer.
This is the key to the whole interop story. The bytes Wasm computes on are the same bytes JavaScript can see. You lay a typed-array view over the buffer and you are reading Wasm’s memory directly — no copy, no serialization.
Memory is measured in pages of 64 KiB (65,536 bytes). You create one with an initial and maximum size, and it can grow at runtime:
const mem = new WebAssembly.Memory({ initial: 2, maximum: 100 });
// initial: 2 pages = 128 KiB, may grow to 100 pages = 6.4 MiB
console.log(mem.buffer.byteLength); // 131072
mem.grow(1); // add one page
console.log(mem.buffer.byteLength); // 196608
There is a sharp edge here worth burning into memory: grow() detaches the old ArrayBuffer. After a grow, any typed-array view you were holding over mem.buffer points at a now-empty, zero-length buffer. This bites people constantly.
Crossing the boundary
Numbers cross the JS↔Wasm line for free. When you call an exported function with an i32 argument, the value passes through a CPU register. When it returns an f64, same thing. No allocation, no marshalling. This is why Wasm shines at arithmetic: a function that takes two numbers and returns a number is essentially as cheap to call as a JIT-compiled JS function.
Strings and arrays are a different story. Wasm’s core types are just those four numeric kinds — it has no concept of a string or an object. So a JS string cannot “be passed” to Wasm at all. What actually happens: you allocate space in the linear memory, copy the bytes in, and pass the offset (a number) to Wasm. On the way back you read bytes out of memory at an offset the function gives you and reconstruct a string. That copy is the cost.
The practical rule that falls out of this: push the boundary crossings down and the work up. You want to hand Wasm a big buffer once, let it churn on the whole thing, and read the result once. A design that calls a tiny Wasm function a million times in a JS loop — passing a string each time — can easily be slower than pure JS, because you pay the copy on every call and starve the engine of the tight-loop optimization Wasm was supposed to give you.
Loading a module
The recommended way to load is WebAssembly.instantiateStreaming — it compiles the module while it downloads, off the main thread, instead of waiting for the whole file then parsing it.
const importObject = {
env: {
log: (value) => console.log('wasm says', value),
},
};
const { instance, module } = await WebAssembly.instantiateStreaming(
fetch('/math.wasm'),
importObject,
);
instance.exports.doubleIt(21); // 42
Three things to get right:
- The server must send
Content-Type: application/wasm. Streaming compilation refuses to start otherwise, and you fall back with a cryptic error. If you can’t fix the MIME type, use the non-streaming path:WebAssembly.instantiate(await (await fetch(url)).arrayBuffer(), importObject). - The first argument is a
Responseor a promise of one — you pass thefetch()call directly, not an awaited buffer. That is what makes streaming possible. - The
importObjectsupplies everything the module declared as an import — host functions, aMemory, aTable, globals. If a declared import is missing, instantiation throws aLinkError.
The returned object has both instance (the live thing with exports) and module (the stateless compiled artifact). Keep the module if you want to spin up more instances or ship it to a worker.
A worked example, end to end
Let’s make the interop concrete with a function that sums an array of numbers — the kind of tight numeric loop Wasm is built for. We won’t hand-write bytecode; assume sum.wasm was compiled from this Rust:
#[no_mangle]
pub extern "C" fn sum(ptr: *const f64, len: usize) -> f64 {
let slice = unsafe { std::slice::from_raw_parts(ptr, len) };
slice.iter().sum()
}
It takes a pointer and a length — not an array, because Wasm has no array type. On the JS side we must place our numbers into the module’s linear memory and pass the offset:
const { instance } = await WebAssembly.instantiateStreaming(
fetch('/sum.wasm'),
);
const { sum, memory, alloc } = instance.exports;
const numbers = [1.5, 2.5, 3, 4, 5];
// Ask the module for space, then write our f64s into its memory.
const ptr = alloc(numbers.length * 8); // 8 bytes per f64
const view = new Float64Array(memory.buffer, ptr, numbers.length);
view.set(numbers); // copy JS → linear memory
const total = sum(ptr, numbers.length); // near-native loop
console.log(total); // 16
Read that carefully, because it is the whole pattern in miniature. The numbers are copied once into shared memory. The pointer that crosses the boundary is a plain integer. The actual summation — the part that would dominate for a million-element array — runs entirely inside Wasm with zero boundary traffic. Grow the array to ten million elements and the fixed cost of that one copy disappears into the noise, while the loop runs at native speed.
Notice view is created after alloc. If alloc had to grow the memory, memory.buffer would be a fresh, detached buffer — so we read memory.buffer at the last possible moment. Same trap as before.
Where Wasm earns its keep — and where it doesn’t
Wasm is a scalpel, not a hammer. It pays off in a fairly specific band of problems.
The DOM point deserves emphasis. Wasm cannot touch the DOM directly — every document.querySelector from Wasm is actually a call back into a JS import, crossing the boundary. A UI rewritten “in Wasm” spends its life ping-ponging across that line and is usually slower, not faster. The winning architecture is almost always: JavaScript owns the DOM and the app; Wasm owns the one hot computation. Photoshop-on-the-web, Figma’s rendering core, ffmpeg.wasm, and SQLite-in-the-browser all follow exactly this split.
Two honest caveats about that chart. First, a warm JIT on a well-typed JS loop is genuinely excellent — Wasm’s edge over it is often modest, not the 10x you sometimes hear. Second, Wasm’s real advantages are predictability (no deopt cliffs, no GC pauses in the base spec) and portability of existing native code, as much as raw peak speed.
Toolchains at a glance
You never write .wasm by hand. You pick a source language and a toolchain that targets it.
wasm-bindgen it generates the JS glue that hides the pointer-copying — strings and structs “just work” across the boundary. The most ergonomic modern path.A wrinkle worth knowing: tools like wasm-bindgen and Emscripten emit a .wasm file and a JavaScript glue module. That glue does exactly the boundary work this article described — allocating, copying strings, managing the memory view — so you call a clean JS function and never touch a pointer. It is convenient and correct. Just remember the copy is still happening under the hood; the glue hides it, it does not abolish it.
Threads, SIMD, and the near future
The core spec keeps growing. Two extensions are already broadly shipping and matter for performance-minded work:
- SIMD (
v128) — single-instruction-multiple-data ops that process four floats at once, a big win for codecs and image filters. - Threads — real shared-memory parallelism via a
SharedArrayBuffer-backedWebAssembly.Memoryand Web Workers. This is how ffmpeg.wasm uses all your cores. It requires the same cross-origin isolation headers as any shared memory (COOP+COEP), so it is not free to turn on.
Because Wasm threads lean on SharedArrayBuffer and workers, the natural home for heavy Wasm is off the main thread entirely — instantiate it inside a Web Worker so a long computation never janks your UI. Compiled WebAssembly.Module objects are postMessage-able, so you can compile once on the main thread and ship the module to the worker without recompiling.
Also maturing: WasmGC, which lets languages with garbage collection (Java, Kotlin, Dart, OCaml) target Wasm while sharing the engine’s garbage collector instead of shipping their own. It has landed in current engines and is how, for example, Dart/Flutter and Kotlin now compile to compact Wasm. If you tried Wasm years ago and bounced off “no GC,” that story has changed.
Summary
- WebAssembly is a compact, sandboxed, stack-based bytecode — a compilation target that runs beside JS at near-native speed. It is W3C-standard and Baseline widely available in browsers.
- The model is Module (stateless compiled code) → Instance (live, stateful) with its Memory (a flat
ArrayBuffer) and Table (references for indirect calls). - Linear memory is a single
ArrayBufferboth sides read and write, so JS and Wasm share data with no copy — via typed-array views. Butmemory.grow()detaches the old buffer; always re-read.bufferafter a call that might allocate. - Numbers cross the boundary for free; strings and objects don’t exist in Wasm and must be copied into linear memory and passed by offset. Design for much work per crossing, few crossings.
- Load with
WebAssembly.instantiateStreaming(fetch(url), importObject)— needs theapplication/wasmMIME type; supply every declared import or get aLinkError. - Wasm wins for codecs, crypto, image/video, games, and porting native code. JavaScript stays better for DOM, glue, and app logic — Wasm can’t touch the DOM without crossing back.
- Toolchains: Emscripten (C/C++), wasm-pack + wasm-bindgen (Rust, ergonomic glue), AssemblyScript (TypeScript-like). SIMD, threads, and WasmGC are shipping; WASI / the component model are a separate, not-yet-browser track.