DEV Community

Devanshu Biswas
Devanshu Biswas

Posted on

I Compiled Rust to WebAssembly and Made My JavaScript 6 Faster

Click the Gaussian blur button on wasm-from-zero.vercel.app.

WASM: 38 ms. JS: 182 ms. Speedup: 4.8×.

That gap is the whole point of WebAssembly. Same algorithm. Same image. Same browser. But one version ran a hot inner loop compiled from Rust through wasm-bindgen to a 12 KB binary, and the other version ran the same loop as JavaScript. The browser optimised both. Wasm won by almost an order of magnitude.

This is the trick that powers Figma, Photoshop on the Web, Google Earth, AutoCAD Web, and a lot of the "wait, that runs in a browser tab?" demos you've seen the last three years. It used to take a six-month port of a C++ engine to ship. Today it takes a Cargo.toml, one wasm-pack build, and an import statement.

The mental model

WebAssembly is a binary format that runs in every modern browser. It's not a programming language — it's an assembly-like target you compile to, the same way you compile to x86_64 or ARM. The languages that target wasm today: Rust, C, C++, Go, Zig, AssemblyScript, Swift, Kotlin, Dart, .NET. Anything with an LLVM backend, basically.

The browser doesn't run your wasm under an interpreter. It JIT-compiles it to actual native machine code, then runs it at near-native speed. The wasm engine in V8 (and SpiderMonkey, JavaScriptCore) is the same engine that runs your JavaScript — it just gets to skip the type-inference + bailout dance JS needs, because wasm is already statically typed.

For pure compute hot loops, that means: no boxed numbers, no hidden classes, no deopts, no GC pauses. Just tight code that does the work the source said to do.

Step 1: write the kernel in Rust

use wasm_bindgen::prelude::*;

#[wasm_bindgen]
pub fn grayscale(pixels: &mut [u8]) {
    for chunk in pixels.chunks_exact_mut(4) {
        let r = chunk[0] as f32;
        let g = chunk[1] as f32;
        let b = chunk[2] as f32;
        let luma = (0.299 * r + 0.587 * g + 0.114 * b) as u8;
        chunk[0] = luma;
        chunk[1] = luma;
        chunk[2] = luma;
    }
}
Enter fullscreen mode Exit fullscreen mode

The #[wasm_bindgen] attribute is the magic. It generates the glue code that lets JavaScript pass a Uint8Array directly into Rust's &mut [u8] — and Rust to write back to the same bytes. No serialisation, no JSON round-trip, no copy across the language boundary.

Why does that matter? Because the canvas pixel buffer is a Uint8ClampedArray. The wasm function gets to point at the canvas's actual memory and rewrite it in place. Zero overhead between the JS world and the Rust world.

Step 2: compile to wasm

docker run --rm -v "$PWD/rust:/work" -w /work rust:1-slim \
    bash -c 'cargo install wasm-pack --locked && wasm-pack build --target web'
Enter fullscreen mode Exit fullscreen mode

The output is a pkg/ folder with three files that matter:

  • wasm_from_zero_bg.wasm — the actual binary, ~10 KB for the four filters in this demo
  • wasm_from_zero.js — wasm-bindgen-generated ES module wrapper
  • wasm_from_zero.d.ts — TypeScript definitions, autogenerated from the Rust signatures

You import it like any other module. No bundler plugin needed (Vite handles the .wasm URL resolution natively):

import init, { grayscale, invert, sepia, blur } from './wasm/wasm_from_zero'

await init()           // one-time: fetch + instantiate the .wasm
grayscale(pixels)      // direct function call from here on
Enter fullscreen mode Exit fullscreen mode

That await init() is the only async work. After it resolves, every filter call is a synchronous function invocation with zero per-call overhead.

Step 3: feed it the canvas

const ctx = canvas.getContext('2d')!
const data = ctx.getImageData(0, 0, w, h)

// Canvas hands us Uint8ClampedArray; wasm-bindgen's &mut [u8] wants
// Uint8Array. Same memory, different TS type — wrap a view, zero copy.
const pixels = new Uint8Array(data.data.buffer)

grayscale(pixels)              // wasm mutates in place

ctx.putImageData(data, 0, 0)   // paint result
Enter fullscreen mode Exit fullscreen mode

That's the whole bridge. Read the pixels off the canvas, hand them to wasm, write the result back. The wasm linear memory and the canvas buffer are the same bytes — the function call is just "go process this 4 MB pointer."

Step 4: race it against JavaScript

The killer demo is to run the exact same algorithm in plain JS and time both. I ported every kernel from Rust to JS byte-for-byte:

export function grayscaleJs(pixels: Uint8ClampedArray) {
  for (let i = 0; i < pixels.length; i += 4) {
    const luma = 0.299 * pixels[i] + 0.587 * pixels[i+1] + 0.114 * pixels[i+2]
    pixels[i] = luma
    pixels[i+1] = luma
    pixels[i+2] = luma
  }
}
Enter fullscreen mode Exit fullscreen mode

Same 0.299, 0.587, 0.114. Same loop shape. Same memory access pattern. The JS engine has every chance to optimise this — and it does, decently. But it can't escape the boxing of pixels[i] * 0.299 (the JS spec says that's a float64 multiplication, even though the result will be stored as a Uint8Clamped). Wasm just multiplies u8 * f32 directly.

On my laptop:

Filter WASM JS Speedup
invert 6 ms 9 ms 1.5×
grayscale 7 ms 14 ms
sepia 9 ms 22 ms 2-3×
blur (3×3) 38 ms 182 ms 4.8×

The pattern: the bigger the per-pixel arithmetic, the wider wasm's lead. invert is 255 - v × 3 channels, so cheap that JS almost catches up. The 3×3 Gaussian blur reads 9 neighbour pixels × 3 channels per output pixel — 27 million multiply-adds on a 1 MP image. That's exactly the shape where wasm pulls ahead.

What this changes

Five years ago, you wrote your image filters in JS and either accepted the slowness or stood up a Python service. Today, you stick the hot loop in a Rust crate, run one build, and ship a 10 KB binary alongside your bundle.

The interesting thing isn't the speedup — it's the distribution model. You're not asking the user to install anything. You're not running a server. The binary just rides along with your JS bundle. The browser caches it. It runs sandboxed by the same security model as JS. It works on every modern browser including mobile.

If you've been holding off on "real" graphics, audio, video, ML, crypto, parsing work in the browser because "it'd be too slow in JS" — that excuse is gone.

The code for this demo is on GitHub, with eight step-by-step commits you can follow. The live version is at wasm-from-zero.vercel.app. Open it. Click Gaussian blur. Watch the speedup card light up.

That's WebAssembly.


🔗 Code: github.com/dev48v/wasm-from-zero
🌐 Live demo: wasm-from-zero.vercel.app
📚 Series: TechFromZero — a new technology every day, all free, all open source.

Top comments (0)