A teammate ships a feature that lets users drop a 40 MB image into the browser, crop it, sharpen it, and re-export as a smaller JPEG before upload. It works on his M2 in development. It crashes a Pixel 6 in QA. The pure-JS image processing loop is the bottleneck — not the network, not the canvas, not the React tree, but the inner loop that walks every pixel and does math on it. The fix is not to optimize the loop in JavaScript. The fix is to stop using JavaScript for that loop.
This is the slot WebAssembly fits into. Not "the future replacement for JavaScript," not "the way you write React in Rust now," but a precise, narrow, sometimes-magical escape hatch for the small subset of work that JS is genuinely the wrong tool for. You will not write much WASM by hand. You will load it from packages other people built, and once or twice a year you will write a small chunk yourself. Knowing where the line is matters more than knowing the toolchain.
What WASM Actually Is
JavaScript is parsed, compiled, and JIT-optimized as the engine sees your code run. V8 is shockingly good at this. For most application work — orchestrating the DOM, handling events, doing async I/O — JS is fast enough that the bottleneck is somewhere else.
WebAssembly is a low-level binary format. You write code in Rust, C, C++, AssemblyScript, Go, or another supported language and compile it ahead of time into a .wasm module. The browser loads that module, validates it, and runs it on the same engine that runs your JS, but without the parse-and-warm-up curve. For tight numeric loops on Float32Arrays, it is regularly 2x to 10x faster than the equivalent JS, with much more predictable performance — no deoptimizations when a hidden class changes, no surprise garbage collection pauses.
The trade-off is the boundary. Calling from JS into WASM and back is not free. You pay for argument marshaling, especially for anything that is not a number. Strings have to be encoded. Objects have to be flattened. Arrays usually pass via shared memory, which works well but adds setup. If you cross the boundary a million times in a loop, you can lose all your speedup to call overhead. WASM rewards bulk operations.
Where It Pays Off In Real Apps
Almost every real-world WASM win is one of a few shapes.
Image and video codecs. ffmpeg.wasm runs the actual ffmpeg in the browser. The Squoosh team ships codecs for MozJPEG, OxiPNG, AVIF, and WebP as WASM modules. Image conversion that would freeze a JS implementation runs comfortably in a worker.
Embedded databases. sql.js is SQLite compiled to WASM. You get a real SQL engine in the browser, in-memory or persisted to IndexedDB, with the same query plan you would run on the server. DuckDB-Wasm does the same for analytical queries on Parquet files, which is wild and useful.
Language runtimes. Pyodide is CPython compiled to WASM. You can run Python in a browser tab, including NumPy and a chunk of the scientific stack. The Ruby team ships ruby.wasm. PHP, R, and Lua all have variants. None of these are fast enough to replace a real Python server, but they are perfect for "let users run a snippet in the docs."
Audio, signal processing, and ML. Tone.js-style synthesis, real-time pitch shifting, FFT-heavy work. ONNX Runtime Web ships models as WASM. For inference up to a certain size, this beats shipping data to a server.
Cryptography. Argon2, libsodium, age — there are mature WASM builds of all of them. Hashing large files locally before upload, end-to-end encrypted client apps, zero-knowledge proofs.
3D, physics, and games. The whole Unity and Unreal "play in browser" pipeline rides on WASM. Bevy, the Rust game engine, targets it natively. Excalidraw, Figma's earlier engine, rendering libraries like Skia.
The pattern: the work is CPU-bound, the data structures are dense, and the boundary crossings are rare relative to the inner-loop work.
Where It Loses
The same logic in reverse tells you when not to use WASM.
DOM-heavy work. Calling document.createElement from inside WASM has to bounce back through the JS bridge for every call. The overhead destroys the speedup. React, Vue, Svelte are not slow because of JavaScript — they are doing exactly the kind of work JS engines are tuned for.
Anything where the data is mostly strings, mostly objects, or mostly small. JSON parsing, form validation, routing — V8 is already very good at this.
Glue code. If your function spends most of its time calling other JS functions, dragging it into WASM only adds a boundary.
Tasks with a lot of branching and unpredictable control flow. WASM still wins, but by less than the toolchain cost is worth.
The honest test: profile the JS first. If a single function dominates and that function is doing arithmetic on a typed array, WASM is on the table. If the time is spread across many small things, WASM will not save you and a quieter optimization will.
The Toolchain Today
You almost never write .wat (WebAssembly text format) by hand. Three pipelines cover most of what people actually ship.
Rust + wasm-pack. This is the modern default. You write Rust, annotate exported functions with #[wasm_bindgen], run wasm-pack build, and get an npm-compatible package out the other side. The generated TypeScript bindings handle string encoding and array marshaling for you. The Rust ecosystem has the strongest WASM tooling — if you only learn one path, learn this.
use wasm_bindgen::prelude::*;
#[wasm_bindgen]
pub fn box_blur(pixels: &mut [u8], width: u32, height: u32, radius: u32) {
// CPU-bound pixel work that JS would struggle with
}
import init, { box_blur } from './pkg/image_filters.js';
await init();
box_blur(rgbaBytes, w, h, 3);
AssemblyScript. TypeScript-flavored syntax that compiles to WASM. The ergonomics are familiar to any JS developer. The output is not as fast as Rust for many workloads, but the ramp is by far the gentlest. Good for "I want a small custom routine without learning a new language."
Emscripten. The original C/C++ to WASM toolchain. It is what compiled SQLite, ffmpeg, and most of the heavy ports you have seen. You will probably never invoke it directly — you will install the npm package that wraps the build it produced.
For loading, the modern primitive is WebAssembly.instantiateStreaming(fetch('...')) — it compiles the module while it streams, which is faster than the older download-then-compile flow.
Workers, Threads, And The Cross-Origin Isolation Headers
Most CPU-bound WASM should not run on the main thread. You spin it up in a Web Worker, post the input across, get the result back. Same as you would for any heavy JS work.
If you want real WASM threads — SharedArrayBuffer, Atomics, the threading proposal — your page has to be cross-origin isolated. That means serving these two response headers:
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
Setting them is straightforward. The catch is that any cross-origin asset you load — images, fonts, third-party iframes — needs to opt in via CORP/CORS, or the browser blocks it. Most teams find out about this when their existing analytics tags break, and they back off threading until they can do an audit. This is fine. Single-threaded WASM in a worker is already a huge win for most use cases.
Where The Spec Is Going
Two threads worth tracking, because they will change the calculus over the next couple of years.
The component model and the WASM components proposal aim to make WASM modules from different languages compose cleanly without bespoke binding code per pair of languages. If it lands as designed, you will be able to consume a Rust module from a Go module from your JS app without any of them knowing about the others.
WASI (the WebAssembly System Interface) brings standardized OS-like APIs — files, sockets, clocks — to WASM running outside the browser. The interesting bit for frontend engineers is that the same WASM modules increasingly run on edge platforms (Cloudflare Workers, Fastly Compute) and in serverless runtimes, which means the line between "client code" and "server code" gets a little blurrier in a useful way.
Neither of these is required reading yet. They are the answer to "is this technology going somewhere," and the answer is yes.
The Boundary Is The Whole Game
Almost every WASM disappointment I have seen on real teams traces back to misjudging the boundary between JS and WASM. The boundary is not free, and it is not symmetric.
Numbers cross cheaply. Pass an i32 or an f64 from JS to WASM and the cost is roughly a function call. Pass it back, same.
Typed arrays cross via shared linear memory. Your WASM module gets a chunk of memory, you write to it from JS, the WASM code reads it directly. This is the path you want for image data, audio buffers, sensor streams.
Strings and objects are where it gets expensive. Every string has to be encoded into bytes, copied across, and decoded. wasm-bindgen automates this and it is fast, but a million small string crossings will still dominate your runtime. Batch your work. Build a single big input, call once, read a single big output.
DOM nodes and JS objects do not really cross at all. They live on the JS side, and WASM has to make an FFI-like call back through the bridge for every interaction. This is why "rewrite my React app in Rust" is not the move.
The mental check before writing any WASM: how many times will I cross the boundary, relative to how much work I will do per crossing? If the answer is "I cross once and crunch a megabyte of pixels," WASM is a great fit. If the answer is "I cross once per pixel," you have not designed your function correctly yet.
A Realistic First Project
If you have never shipped WASM and want a small, useful first project, here is one that has paid off for several teams I have worked with.
Pick a single function in your app that profiles as the bottleneck — image resize before upload is the canonical one. Find the existing pure-JS implementation. Write the equivalent in Rust, build it with wasm-pack, and load it in a worker. Keep the JS version around behind a feature flag. Compare INP and time-to-process on real devices, not just your laptop.
If you see a clear win and the code is small enough that you do not mind maintaining it, ship it. If the win is marginal, delete the experiment and remember the result. WASM is not free — there is a build step, a .wasm file to deploy, and a small runtime cost — and "we tried and it did not help" is a perfectly fine outcome.
What you should not do on a first project is rewrite a large chunk of your app. The bridge between JS and WASM is where most of the friction lives, and the way to learn the bridge is to cross it once, in a contained piece of code, before you build a whole architecture around it.
// load and use a Rust-built module from a worker
import init, { resize_jpeg } from './pkg/image_filters.js';
self.onmessage = async (e) => {
await init();
const { bytes, width, height } = e.data;
const out = resize_jpeg(new Uint8Array(bytes), width, height, 0.75);
self.postMessage(out, [out.buffer]);
};
Two facts that surprise people the first time they ship this. The .wasm file caches well — once a user has loaded it, they do not pay for it on subsequent visits. And the wasm-pack output is npm-publishable, so if you find yourself with a useful primitive, you can package and reuse it across services without a custom toolchain.
A One-Sentence Mental Model
WebAssembly is not a replacement for JavaScript — it is the precise tool you reach for when a single hot loop on a typed array is the reason your page feels slow, and pretty much every other time you should keep writing JS.





