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.

C · Rust · C++source you writecompileremcc · wasm-packapp.wasmcompact binarythe browser tabWasmJScall
The pipeline: source in a systems language is compiled ahead of time to a .wasm binary, which the browser fetches, validates, compiles to machine code, and runs in the same page as your JavaScript.

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.

Module
Stateless compiled code. Validate once, cache it, share it with workers. Holds no data.
Instance
A running copy of a Module wired to its imports. Its exports are the functions you call.
Memory
A resizable ArrayBuffer of raw bytes — the instance’s entire heap. JS can read and write it too.
Table
A typed array of opaque references — usually function pointers — for indirect calls and dynamic dispatch.
Module is the compiled recipe; Instance is a live copy with its own state; Memory is the flat byte array it computes in; Table holds references (typically functions) that Wasm can call indirectly.

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.

one ArrayBuffer — the instance’s entire heap3.14 · 420byteWasmwrites at offset 216JavaScriptnew Float64Array(mem.buffer)no copy — both sides address the same bytes
Linear memory is one ArrayBuffer. Wasm addresses it by byte offset; JavaScript lays typed-array views over the same bytes. A write on either side is instantly visible to the other because there is only one copy.

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.

JavaScriptWasm42.0rides a register — freef64“hello world”encode to UTF-8 bytescopy into linear memorybytespass offsetptr, len
Two very different boundary costs. A number rides a register straight across. A string must be encoded into bytes, copied into linear memory, and passed as an offset — real work that scales with length.

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:

  1. 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).
  2. The first argument is a Response or a promise of one — you pass the fetch() call directly, not an awaited buffer. That is what makes streaming possible.
  3. The importObject supplies everything the module declared as an import — host functions, a Memory, a Table, globals. If a declared import is missing, instantiation throws a LinkError.
What instantiateStreaming does, step by step
1/4
Variables
imports.env.log="fn"
You build the import object: host functions and memory the module is allowed to reach.

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.

Reach for Wasm
  • Codecs — audio, video, image encode/decode
  • Crypto and hashing (argon2, zstd, brotli)
  • Image processing, resize, filters, OCR
  • Games and physics engines
  • Porting an existing C / C++ / Rust library
  • Number-crunching: simulation, ML inference kernels
Stay in JavaScript
  • DOM reads and writes, event handling
  • Layout, styling, animation glue
  • Network orchestration and app logic
  • Anything dominated by string / object churn
  • Small, cheap functions called constantly
  • Most of your codebase, honestly
Rough guide. The left column is CPU-bound, self-contained, number-heavy work that barely touches the DOM — Wasm's home turf. The right column is glue and browser-API work where JavaScript is the native, faster choice.

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.

Relative time on a tight numeric loop (lower is faster) — illustrativeJS (cold)JS (JIT hot)Wasmnativeslow~1.7x~1.2x1.0x
Illustrative only — real numbers depend wildly on the workload. For a self-contained numeric hot loop, Wasm often lands in the 1.2x–2x range of native and comfortably ahead of un-JIT-friendly JS. For DOM-bound work the picture inverts.

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.

EmscriptenC / C++. Emulates a POSIX-ish environment and can even auto-generate OpenGL→WebGL glue. The path for porting a big existing codebase.
wasm-packRust. With 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.
AssemblyScriptA strict TypeScript subset compiled straight to Wasm. Tiny output, familiar syntax, no C toolchain — great for a single hot function without leaving the JS world.
The three common on-ramps for browser Wasm, by starting point. Emscripten for existing C/C++, wasm-pack for Rust, AssemblyScript if you want to stay close to TypeScript.

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-backed WebAssembly.Memory and 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 ArrayBuffer both sides read and write, so JS and Wasm share data with no copy — via typed-array views. But memory.grow() detaches the old buffer; always re-read .buffer after 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 the application/wasm MIME type; supply every declared import or get a LinkError.
  • 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.