WebAssembly for High-Performance Web Applications
How WebAssembly enables near-native performance in the browser, with practical use cases, toolchains, and the JavaScript interop model.
Contents
What WebAssembly Actually Is
WebAssembly (Wasm) is a binary instruction format that browsers execute at near-native speed. It is not a replacement for JavaScript — it is a compilation target. You write code in Rust, C, C++, Go, or AssemblyScript; compile it to .wasm; and load it in the browser alongside JavaScript.
The performance advantage comes from two things: Wasm is a compact binary format that parses faster than JavaScript source, and Wasm's type system allows ahead-of-time compilation that avoids the speculative optimization/deoptimization cycles that make JavaScript performance unpredictable.
When Wasm Helps (and When It Doesn't)
Wasm excels at:
- CPU-bound computation (image processing, video encoding/decoding, cryptography, compression, simulation)
- Code that needs predictable, consistent performance (real-time audio, game physics)
- Porting existing C/C++ libraries to the web without rewriting them
Wasm does not help with:
- DOM manipulation (Wasm has no native DOM access — it calls JS to touch the DOM, which is slower)
- Network I/O (same browser APIs as JS)
- Memory-bound tasks where cache behavior dominates
If your bottleneck is DOM manipulation, CSS, network, or storage — Wasm will not help and may make things worse due to JS/Wasm call overhead.
Rust to WebAssembly with wasm-pack
Rust's Wasm toolchain is the most mature for web targets. wasm-pack handles compilation, JavaScript bindings generation, and npm publishing.
cargo install wasm-pack
cargo new --lib image-processor
# Cargo.toml
[lib]
crate-type = ["cdylib"]
[dependencies]
wasm-bindgen = "0.2"
js-sys = "0.3"
// src/lib.rs
use wasm_bindgen::prelude::*;
#[wasm_bindgen]
pub fn apply_grayscale(data: &mut [u8]) {
// ImageData is RGBA, 4 bytes per pixel
for chunk in data.chunks_mut(4) {
let r = chunk[0] as f32;
let g = chunk[1] as f32;
let b = chunk[2] as f32;
// ITU-R BT.709 luminance coefficients
let gray = (0.2126 * r + 0.7152 * g + 0.0722 * b) as u8;
chunk[0] = gray;
chunk[1] = gray;
chunk[2] = gray;
// chunk[3] = alpha, unchanged
}
}
Build:
wasm-pack build --target web --out-dir pkg
This generates pkg/image_processor_bg.wasm and pkg/image_processor.js (the JS glue). Use it:
import init, { apply_grayscale } from './pkg/image_processor.js';
await init();
const canvas = document.querySelector('canvas');
const ctx = canvas.getContext('2d');
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
// Pass a view into the Wasm memory — zero copy
apply_grayscale(imageData.data);
ctx.putImageData(imageData, 0, 0);
The critical insight: imageData.data is a Uint8ClampedArray backed by the browser's memory. When Rust receives &mut [u8], the JS binding creates a view into Wasm's linear memory. For large arrays, this avoids copying gigabytes of pixel data across the Wasm/JS boundary.
Memory Model and Zero-Copy Patterns
Wasm has a flat, linear memory model. Sharing data between JS and Wasm without copying is the key optimization:
// Allocate in Wasm memory, get a pointer
const ptr = wasm_module.allocate(1024 * 1024); // 1 MB
// Create a JS view into Wasm's memory (no copy)
const wasmMemory = new Uint8Array(wasm_module.memory.buffer, ptr, 1024 * 1024);
// Write data directly into Wasm memory
wasmMemory.set(inputData);
// Call Wasm function on the data (in-place)
wasm_module.process(ptr, 1024 * 1024);
// Read result from Wasm memory (no copy)
const result = wasmMemory.slice(0, 1024 * 1024);
This zero-copy pattern is essential for performance. Every copy between JS and Wasm memory is an O(n) operation. For a 4K image (about 33 MB of pixel data), unnecessary copies destroy any Wasm performance advantage.
Threading with SharedArrayBuffer
Wasm supports threads via the SharedArrayBuffer Web API and Atomics. The setup requires HTTP headers:
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
With those headers in place:
// In Rust, use rayon for data-parallel computation
use rayon::prelude::*;
#[wasm_bindgen]
pub fn process_parallel(data: &mut [u8]) {
data.par_chunks_mut(4).for_each(|pixel| {
// Each chunk processed on a separate thread
let [r, g, b, _] = pixel else { return };
let gray = (0.2126 * *r as f32 + 0.7152 * *g as f32 + 0.0722 * *b as f32) as u8;
*r = gray; *g = gray; *b = gray;
});
}
For a 4K image, parallel Wasm with 4 threads can be 3–3.5x faster than single-threaded Wasm.
Real-World Use Cases in Production
Figma uses Wasm for its rendering engine — the C++ layout and rendering code compiles to Wasm, while JS handles UI events and network. This lets them match desktop application performance in a browser.
Google Earth uses Wasm to run the 3D globe rendering, originally C++ code, without rewriting it in JavaScript.
Squoosh (Google's image compression tool) ships WebP, AVIF, and JPEG XL encoders as Wasm — each is a C/C++ library compiled to Wasm, enabling in-browser compression at near-native speed.
FFmpeg.wasm brings the full FFmpeg video processing library to the browser. A video conversion that would have required a server-side upload now runs locally.
Toolchain Summary
| Language | Tool | Maturity | Use when |
|---|---|---|---|
| Rust | wasm-pack / wasm-bindgen | Excellent | New code, need safety |
| C/C++ | Emscripten | Excellent | Porting existing libs |
| Go | TinyGo | Good | Go-native teams |
| AssemblyScript | asc | Good | TypeScript-like syntax |
| Python | Pyodide | Moderate | Scientific computing |
Wasm is not a silver bullet, but for computationally intensive tasks in the browser, it is the only path to performance that rivals native code. The toolchain has matured significantly — the cost of adoption is lower than ever, and the performance ceiling is genuinely near-native.