Skip to main content

The Pattern

A Rust crate compiled to a 20 KB wasm module, vendored into the repo and loaded with zero Vite plugins. The dev container never needs a Rust toolchain: a throwaway builder container compiles the crate, and the generated glue and binary are committed like any other source.

The kernel

Pixels live in wasm linear memory. JS writes them through a view once, filter calls cross the boundary with two scalars, and a vitest parity gate keeps the Rust and JS implementations byte-identical.

crates/kernel/src/lib.rs
#[wasm_bindgen]
pub struct PixelKernel {
    width: u32,
    height: u32,
    pixels: Vec<u8>,   // JS writes through a view over pixels_ptr() — once
    scratch: Vec<u8>,  // preallocated: no filter call may grow wasm memory
}

#[wasm_bindgen]
impl PixelKernel {
    /// Filter calls cross the boundary with two scalars, never with the frame.
    pub fn box_blur(&mut self, radius: u32) { /* (2r+1)² taps per pixel */ }
    pub fn grayscale(&mut self) { /* Rec.601 integer luma */ }
}

The build

One host-side script, one ephemeral Rust container, artifacts committed. Reproducibility is pinned by a digest-pinned builder image, rust-toolchain.toml and Cargo.lock — and a committed build manifest turns the gate red if sources and artifacts drift apart.

scripts/wasm/build.sh
# scripts/wasm/build.sh — the v10r container stays Rust-free
RUST_IMAGE="docker.io/library/rust:1.97-slim@sha256:8e8cf8…"  # digest-pinned

podman run --rm -v "$PWD:/work" -w /work/crates/kernel "$RUST_IMAGE" bash -c '
    cargo build --release --target wasm32-unknown-unknown
    wasm-bindgen --target web --out-dir /work/src/lib/wasm/kernel \
      target/wasm32-unknown-unknown/release/v10r_kernel.wasm'

# kernel.js + kernel_bg.wasm (+ .d.ts) are COMMITTED, plus build-manifest.json:
# sha256 of sources and artifacts — a vitest gate recomputes them, so a Rust
# edit without a rebuild goes red. `bun run validate` never needs Rust.

The loader

?url plus an explicit init call sidesteps every known dev-vs-build wasm divergence in Vite and SvelteKit — no wasm plugin, no top-level await, nothing executed during SSR.

src/lib/wasm/index.ts
import wasmUrl from './kernel/kernel_bg.wasm?url';

let ready: Promise<Kernel> | null = null;

export function loadKernel(): Promise<Kernel> {
	// ?url + explicit init: no Vite wasm plugins, no top-level await (broken
	// under Svelte 5 — sveltejs/kit#13015), nothing executed during SSR, and
	// the binary ships as a hashed immutable asset.
	if (!ready) {
		ready = import('./kernel/kernel.js').then(async (mod) => {
			const out = await mod.default({ module_or_path: wasmUrl });
			return { mod, memory: out.memory };
		});
	}
	return ready;
}

Filter Lab

The same filter, implemented line-for-line in Rust and JavaScript, over the same synthetic frame. Both engines run in the same worker, the lane order alternates every round, and checksums prove the outputs are identical — algorithm, input, worker and output are controlled; what remains is the language and its runtime.

Waking up the lab — this demo runs entirely in your browser.

The Boundary Tax

One multiply per element over a million floats — almost no compute per byte moved. Three lanes: plain JS, wasm that copies the array across the boundary on every call, and wasm with the data resident in linear memory. Marshalling losing to plain JS is the point.

Waking up the lab — this demo runs entirely in your browser.

Honest Measurement

A benchmark that flatters wasm is easy: time cold JS against warm wasm, hide the copies, report the best run. Every number on this page follows five rules instead:

  • Warm-up rounds run both engines untimed first — un-warmed JS executes in the interpreter rather than the JIT and loses by default.
  • Timed rounds are counterbalanced — which engine goes first alternates every round; a fixed order keeps position bias even in dedicated benchmark libraries.
  • Medians with min–max spread, never means — one GC pause must not move the headline number.
  • The one-time fetch + compile + instantiate cost is shown, not hidden — it is the real price of adopting wasm.
  • Boundary copies are timed as their own stages — end-to-end wins are the only wins that count.

Results are engine-dependent: the same workload can differ by nearly an order of magnitude between browsers. This page reports your engine's numbers, not a universal truth.

When Wasm Wins

Wasm is not performance pixie dust — JavaScript JITs to machine code too, and a warmed JIT can approach or beat wasm on numeric loops. Wasm earns its place when the work is compute-dense and the data crosses the boundary rarely.

Reaches for wasm

  • Dense numeric kernels over typed arrays — convolution, physics, signal processing — with the data resident in linear memory.
  • Predictability: wasm's run-to-run spread is typically tighter — ahead-of-time compilation, no JIT tiers or deopts to fall out of.
  • Reusing an existing Rust or C++ implementation instead of porting it by hand.

Stays in JavaScript

  • String-heavy work — every string crosses the boundary as a UTF-16 → UTF-8 transcode plus a copy.
  • Chatty APIs that copy buffers per call — the marshalling lane above pays for the same lesson.
  • Allocation-heavy object graphs — engine GCs are deeply optimized for them, while a wasm module ships and warms its own allocator.

This repo already runs wasm in production — server-side: shiki's Oniguruma engine highlighted every code block on this page roughly five times faster than its pure-JS fallback.

Think this pattern could be better? Tell us how.

Leave feedback