Tutorial: Learn WebAssembly from Scratch (2026)
WebAssembly (WASM) is a binary instruction format that runs in the browser at near-native speed. After porting a video processing pipeline and a physics simulation from JavaScript to WASM, I have seen execution times drop from seconds to milliseconds. WASM is not a replacement for JavaScript — it is a co-processor for computationally heavy workloads.
This tutorial covers writing WASM modules in Rust, integrating with Browser APIs, measuring performance gains, and deploying WASM to production. You will learn the compile toolchain, the WASM linear memory model, and when WASM truly outperforms JavaScript.
Compiling Rust to WASM
The wasm-pack toolchain compiles Rust to WebAssembly with minimal boilerplate. You mark exported functions with #[wasm_bindgen], which generates JavaScript bindings automatically. The Rust code can use standard library types (String, Vec, numbers) which are marshalled across the JS-WASM boundary.
The cargo wasm build targets the wasm32-unknown-unknown compilation target. wasm-pack outputs a pkg directory containing .wasm binary, JavaScript glue code, and TypeScript type declarations. For web bundlers, simply import the generated module.
use wasm_bindgen::prelude::*;
#[wasm_bindgen]
pub fn mandelbrot(width: u32, height: u32, max_iter: u32) -> Vec {
let mut pixels = Vec::with_capacity((width * height) as usize);
for y in 0..height {
for x in 0..width {
let cx = (x as f64 / width as f64) * 3.5 - 2.5;
let cy = (y as f64 / height as f64) * 2.0 - 1.0;
pixels.push(calc_pixel(cx, cy, max_iter));
}
}
pixels
}
WASM Memory Model
WASM has a linear memory — a contiguous array of bytes accessible to both WASM and JavaScript. Rust's wasm-bindgen handles memory allocation automatically if you use the wee_alloc allocator. For zero-copy access, pass pointers (numbers) and read/write directly from the WASM memory buffer via the JavaScript side.
Shared memory via WebAssembly.Memory allows transferring large binary data (image buffers, audio samples, video frames) without copying. The WASM module allocates memory, writes results, and returns a pointer plus length. JavaScript's Uint8Array overlays the WASM memory buffer.
// Rust: allocate and return pointer
#[wasm_bindgen]
pub fn process_image(data_ptr: *mut u8, len: usize, width: u32, height: u32) {
let pixels = unsafe { std::slice::from_raw_parts_mut(data_ptr, len) };
for chunk in pixels.chunks_mut(4) {
// Apply grayscale filter
let gray = (chunk[0] as u16 + chunk[1] as u16 + chunk[2] as u16) / 3;
chunk[0] = gray as u8;
chunk[1] = gray as u8;
chunk[2] = gray as u8;
}
}
Browser API Access from WASM
wasm-bindgen provides bindings to web APIs: DOM manipulation, canvas, WebGL/WebGPU, fetch, setTimeout, and more. Each API call marshals values across the boundary. For performance-sensitive paths, batch API calls or use shared memory to minimize boundary crossings.
For rendering, the Canvas API via wasm-bindgen or direct WebGL/WebGPU bindings (web-sys, wgpu) bypass JavaScript entirely. This gives full control over the rendering pipeline with no JS overhead.
use web_sys::{CanvasRenderingContext2d, console, window};
#[wasm_bindgen]
pub fn draw_frame(ctx: &CanvasRenderingContext2d, time: f64) {
ctx.clear_rect(0.0, 0.0, 800.0, 600.0);
ctx.set_fill_style(&JsValue::from_str("#ff6600"));
ctx.begin_path();
let x = 400.0 + f64::sin(time * 0.002) * 200.0;
let y = 300.0 + f64::cos(time * 0.003) * 200.0;
ctx.arc(x, y, 50.0, 0.0, 3.14 * 2.0).unwrap();
ctx.fill();
}
Performance Benchmarks
WASM excels at CPU-bound calculations: image processing, audio synthesis, physics simulations, compression, and cryptographic operations. Typical speedups over JavaScript range from 2x to 10x depending on the workload and how well the code exploits SIMD (Single Instruction Multiple Data) instructions available in WASM.
The WASM MVP does not support direct DOM access or GC — operations requiring these incur calling overhead. Measure the total round-trip time including marshalling, not just the raw computation. Use console.time() and performance.now() for profiling.
async function benchmark() {
const { mandelbrot } = await import('./pkg/mandelbrot.js');
console.time('wasm');
const result = mandelbrot(800, 600, 256);
console.timeEnd('wasm'); // ~15ms
console.time('js');
const jsResult = jsMandelbrot(800, 600, 256);
console.timeEnd('js'); // ~180ms
console.log(`WASM is ${(180/15).toFixed(1)}x faster`);
}
Debugging and Profiling WASM
Debug WASM with browser DevTools — Chrome and Firefox support source maps for .wasm files compiled with debug symbols. Enable debugging with wasm-pack build --debug or set the profile.debug configuration. For profiling, use the browser performance panel which shows WASM function timings.
Use console_error_panic_hook for readable panic messages — without it, panics return an opaque 'unreachable' error. For memory profiling, use the Chrome Memory panel's WASM allocation tracking.
# Cargo.toml
[dependencies]
console_error_panic_hook = "0.1"
#[wasm_bindgen(start)]
pub fn init() {
console_error_panic_hook::set_once();
}
// Build with debug symbols
// wasm-pack build --debug
// or in .cargo/config.toml:
// [profile.release]
// debug = true
WASM in Production and Beyond
Beyond the browser, WASM runs on servers via WASI (WebAssembly System Interface). Fastly's Compute@Edge, Cloudflare Workers, and Fermyon Spinoza all run WASM as a sandboxed, fast-starting server runtime. WASI provides file I/O, sockets, and clock access through a POSIX-like interface.
For deployment, optimize binary size with wasm-opt (Binaryen) and enable LTO in Rust. Aim for under 200 KB for web delivery. WASM modules are cached aggressively by browsers — version your module URL for cache busting.
// Optimize binary size
// wasm-opt -Oz -o optimized.wasm input.wasm
// wasm-strip optimized.wasm
// Cloudflare Workers WASM
import mandelbrot from './mandelbrot.wasm';
export default {
async fetch(request) {
const instance = await mandelbrot({ env: {} });
const result = instance.exports.mandelbrot(400, 300, 128);
return new Response(JSON.stringify({ pixels: result }));
}
}
Frequently Asked Questions
Does WASM replace JavaScript?
No. WASM is a complement to JavaScript for CPU-intensive tasks. JavaScript still owns the DOM, event handling, and most application logic. WASM is best used as a module for specific compute-heavy operations.
Can WASM access the DOM directly?
Not in the MVP. WASM must call JavaScript via the wasm-bindgen bridge to manipulate the DOM. The GC proposal and interface types proposal will eventually enable direct DOM access.
What languages compile to WASM?
Rust, C/C++ (via Emscripten), Go, Zig, AssemblyScript (TypeScript-like). Rust has the best tooling and ecosystem for WASM development as of 2026.
How do I handle errors in WASM?
WASM panics propagate to JavaScript as exceptions. Use the console_error_panic_hook crate for readable messages. For expected errors, return Result types that wasm-pack converts to JavaScript exceptions or promise rejections.
Originally published on Ayodhyyya. Last updated June 1, 2026.