Under the Hood
Wasm

Why WASM is fast — and when it isn't

WebAssembly's speed comes from a short list of nameable properties — ahead-of-time compilation with no warmup, static types with no runtime checks, and a flat cache-friendly linear memory — and every one of those properties stops helping the moment a workload is dominated by DOM calls, chatty boundary crossings, or garbage-collected data, which is why reaching for WASM is a judgment call, not a reflex.

Why WASM is fast — and when it isn't

"WebAssembly is fast" gets repeated so often that it starts to sound like a property of the format itself, the way "JPEG is small" is a property of JPEG. It isn't quite that simple, and treating it that way leads to a predictable mistake: rewriting some slow JavaScript in Rust, compiling it to WASM, and getting back code that's barely faster, or in some cases slower, once you account for the glue around it. The truth is more useful than either "WASM is always fast" or "WASM is overhyped": WASM is fast for specific, nameable reasons, and those reasons evaporate for specific, nameable kinds of work. This lesson names both lists.

We already have the two pieces this explanation is built from. Lesson 1 established that WASM's core type system is four static numeric types and nothing else. Lesson 4 established that only those numbers cross into JavaScript directly, and everything else pays a marshaling cost. Put those two facts together and you can derive both when WASM wins and when it doesn't, instead of memorizing a rule.

Why it's fast: four properties, not one

Ahead-of-time compilation, with no warmup. A JavaScript engine starts running your function by interpreting it — walking the bytecode step by step — because it doesn't yet know enough about the shapes of your data to safely generate fast machine code. Only after a function runs "hot" for a while does the JIT compiler generate optimized machine code for it, based on the types and shapes it observed at runtime. And that optimized code is provisional: if a later call shows up with a shape the JIT didn't expect (an object with an extra property, a number where it saw a string before), the engine has to deoptimize — throw away the fast compiled version and fall back to the slow path, sometimes mid-function. WASM skips this whole story. Its types are declared statically in the module, so the browser's WASM compiler can generate machine code for every function before it runs the first instruction, and that code never needs to be thrown away, because the types it was compiled for can never turn out to be wrong at runtime — the module was validated against exactly those types before it was allowed to run at all.

No runtime type checks, boxing, or hidden-class churn. A JavaScript engine represents objects using something like a "hidden class" (a runtime-generated description of an object's shape) so that property lookups can be reasonably fast, but that scheme still means every property access potentially involves a shape check, and objects with unstable shapes force the engine to fall back to slower, more general lookup paths. Numbers get boxed and unboxed as they move between contexts where the engine can't prove a value is a plain machine integer or float. None of that exists in WASM: an i32 is a 32-bit integer, full stop, statically known at compile time, so i32.add compiles straight to a hardware add instruction with zero runtime type interrogation.

Linear memory is flat and cache-friendly. A WASM module's data lives in linear memory — one contiguous, byte-addressable array, laid out exactly the way the source language's compiler decided, with predictable strides between elements. A JavaScript object, by contrast, typically lives somewhere on a garbage-collected heap, and objects allocated around the same time in your code are not guaranteed to sit anywhere near each other in memory, because the engine's allocator and garbage collector are free to move things. Tight, predictable memory layout matters enormously in practice because modern CPUs are dramatically faster at reading memory that's already in cache than memory that requires a fresh trip to RAM; a flat array you iterate sequentially plays to that hardware reality in a way a scattered object graph structurally cannot.

Compact, deterministic instructions close to hardware. Lesson 1's stack-machine encoding was chosen partly because it maps cheaply onto real CPU instructions — there's no ambiguity for the compiler to resolve about what an instruction might mean depending on the shapes of values flowing through it, because the shapes are fixed and declared.

Add those four up and you get code that starts fast and stays fast, with none of JavaScript's "runs slow, warms up, might suddenly cool back down" arc.

When it isn't: the same properties, working against you

None of the four properties above are about JavaScript's engine being bad — modern JS JITs are excellent. They're about WASM sidestepping specific costs JS sometimes pays. Which means the moment your workload's bottleneck isn't one of those costs, WASM's advantage shrinks or disappears.

Anything that touches the DOM or Web APIs. WASM cannot read an element's offsetHeight, add an event listener, or issue a fetch — it has no capability to reach the browser's APIs directly, at all. Every one of those operations has to go back out through the JS boundary lesson 4 covered, as a call into an imported JS function. If your "hot loop" is walking the DOM and reading layout properties, the bottleneck was never raw computation — it's DOM access latency and boundary crossings, and moving the loop's arithmetic into WASM doesn't touch either cost. You've just added a marshaling layer around the same slow operations.

Small, chatty functions. A single boundary crossing is cheap, but it isn't free — and if the payload isn't a bare number, lesson 4's encode-and-copy tax applies on top. Call a WASM function once per array element, once per keystroke, once per row of a table, and the fixed per-call and per-marshal overhead can dwarf the actual work being done inside the call. The fix isn't "don't use WASM," it's "cross the boundary once with the whole batch," which is a design decision, not a compiler flag.

GC-heavy or highly dynamic workloads. Code that's fundamentally about allocating, mutating, and garbage-collecting objects — building and tearing down UI state trees, string-heavy templating, anything with genuinely dynamic shapes — is exactly the workload JavaScript's engines have spent two decades optimizing for. WASM's numeric-and-linear-memory model makes you manage that same dynamism yourself (your own allocator, your own "garbage collection" if you need it, all inside linear memory), which is more, not less, work, for no guaranteed win, since you're now competing with an already highly-tuned generational garbage collector using a hand-rolled substitute.

Download and compile cost for tiny tasks. A .wasm module still has to be fetched and compiled before its first export can run. For a small, one-off task, that fixed cost can exceed the entire runtime of the equivalent JS function. WASM's AOT compilation removes JIT warmup, but it doesn't remove the cost of compiling the module in the first place — lesson 6 covers how the browser tries to hide that cost by compiling while the bytes are still downloading.

The real sweet spots

WASM wins clearly and consistently for work that is compute-bound and stays inside the module for a meaningful stretch of time before it needs to hand a result back to JS. In practice that's a fairly specific list: audio, video, and image codecs; compression and decompression (zip, image formats); cryptography; physics simulation; image and signal processing; game engines; and, generally, porting an existing C, C++, or Rust library that already does one of these things well, rather than rewriting your whole app's architecture around WASM.

WorkloadWASM win?Why
Decoding a video codec frame-by-frameYesPure numeric compute, runs for a long stretch per call, no DOM contact until a decoded frame is handed back once.
Image filters / convolution over a raw pixel bufferYesTight numeric loops over a flat byte array — plays directly to linear memory's cache-friendly layout.
Reading element.offsetHeight in a loopNoBottleneck is DOM access and boundary crossings, not arithmetic; WASM can't touch the DOM itself.
Building and diffing a UI's virtual DOM treeNoGC-heavy, highly dynamic object shapes — exactly what a mature JS JIT already handles well.
Parsing/validating one small JSON payloadNoTask is tiny and one-off; module fetch/compile cost likely exceeds the JS equivalent's total runtime.
Porting an existing crypto or compression C libraryYesCompute-bound, numeric, and the library already exists — no architectural rewrite required.

Complement, not replacement

The honest framing, and the one that actually holds up in production systems: JavaScript stays the language of the page — DOM, events, UI state, glue code, orchestration — because that's what its engine and APIs are built for. WASM is a component you reach for when you've identified a genuinely compute-bound kernel and want it to run at near-native speed without JIT warmup or deopt risk. Most real applications that use WASM well use very little of it, wrapped tightly around one hot path, called into a handful of times with batched data, not sprinkled throughout the codebase.

Where this goes next

You now have the full performance model: what makes WASM's compute fast (lessons 1 and this one), what makes crossing into it costly (lesson 4), and where the two nets out in practice (the table above). Streaming compilation and the VM picks up the piece this lesson only gestured at — the download-and-compile cost — and shows how the browser overlaps compilation with the network fetch so a module can be ready to run close to the moment its bytes finish arriving.

Go deeper

Check yourself

Answer out loud, as if an interviewer asked. If you hand-wave, reread that section.

  1. Name the four properties this lesson gives for why WASM is fast, and for each, name the specific JS-side cost it avoids.
  2. What does it mean for a JS JIT to 'deoptimize,' and why can WASM's compiled code never need an equivalent step?
  3. Why does linear memory's flat layout matter for CPU cache behavior in a way a JS object graph structurally can't match?
  4. A teammate wants to port DOM-heavy UI code to WASM for speed. Using lesson 4's boundary model, explain why that's likely to make things worse, not better.
  5. Give two concrete workloads from the sweet-spot list and explain, in terms of the four fast-properties, why each one is a good fit.
  6. Why does a WASM module's own AOT compilation not eliminate the need for something like streaming compilation to hide download/compile cost?
  7. A long WASM computation is making a page's animations stutter. Is moving the computation into WASM from JS enough to fix that, and if not, what's the actual fix?