Post 09
WebAssembly for Heavy Compute, Off the Main Thread
When a JavaScript loop is janking the UI — image processing, parsing, simulation, crypto — the answer is often Rust compiled to WebAssembly, running in a Worker. Here is the real setup: wasm-pack, Comlink, transferable buffers, and the parts that actually matter.

JavaScript is fast enough for almost everything a UI does. The exceptions are real, though: resizing and filtering images client-side, parsing a large binary format, running a physics or pricing simulation, hashing or diffing big buffers. When one of those blocks the main thread for 200ms, every interaction on the page stutters and your INP falls off a cliff.
The fix has two parts, and you need both: move the work off the main thread (a Worker), and make the work itself faster (WebAssembly). Rust plus wasm-pack is the most ergonomic path today.
The Rust side
1use wasm_bindgen::prelude::*;2 3/// Grayscale an RGBA buffer in place. Takes a mutable view of4/// memory the JS side owns — no copy in, no copy out.5#[wasm_bindgen]6pub fn grayscale(pixels: &mut [u8]) {7 for px in pixels.chunks_exact_mut(4) {8 let luma = (0.299 * px[0] as f329 + 0.587 * px[1] as f3210 + 0.114 * px[2] as f32) as u8;11 px[0] = luma;12 px[1] = luma;13 px[2] = luma;14 // px[3] (alpha) untouched15 }16}1cargo install wasm-pack2wasm-pack build --target web --out-dir ../app/wasm/pkg3 4# emits: pkg/your_crate.js + pkg/your_crate_bg.wasm + .d.ts--target web gives you an ES module that works in a Worker with a plain import. --target bundler needs webpack-specific handling; --target web is the one to use with Next's Turbopack or in a raw Worker.
Load the wasm inside a Worker
1import * as Comlink from "comlink";2import init, { grayscale } from "./pkg/your_crate.js";3 4let ready: Promise<unknown> | null = null;5 6const api = {7 async grayscale(buffer: ArrayBuffer): Promise<ArrayBuffer> {8 ready ??= init(); // compile + instantiate once, lazily9 await ready;10 11 const bytes = new Uint8Array(buffer);12 grayscale(bytes); // mutates in place, in wasm linear memory13 return Comlink.transfer(buffer, [buffer]); // hand ownership back, no copy14 },15};16 17Comlink.expose(api);18export type ImageWorker = typeof api;Call it from the component like a normal async function
1"use client";2import * as Comlink from "comlink";3import { useRef } from "react";4import type { ImageWorker } from "@/app/wasm/image.worker";5 6function useImageWorker() {7 const ref = useRef<Comlink.Remote<ImageWorker>>();8 if (!ref.current) {9 const worker = new Worker(10 new URL("@/app/wasm/image.worker.ts", import.meta.url),11 { type: "module" },12 );13 ref.current = Comlink.wrap<ImageWorker>(worker);14 }15 return ref.current;16}17 18export function ImageEditor() {19 const worker = useImageWorker();20 21 async function onFile(file: File) {22 const bitmap = await createImageBitmap(file);23 const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);24 const ctx = canvas.getContext("2d")!;25 ctx.drawImage(bitmap, 0, 0);26 const imageData = ctx.getImageData(0, 0, bitmap.width, bitmap.height);27 28 // the heavy part — runs in the worker, in wasm, main thread stays free29 const result = await worker.grayscale(imageData.data.buffer);30 31 ctx.putImageData(32 new ImageData(new Uint8ClampedArray(result), bitmap.width, bitmap.height),33 0, 0,34 );35 // paint canvas to the DOM…36 }37 38 return <input type="file" accept="image/*"39 onChange={(e) => e.target.files?.[0] && onFile(e.target.files[0])} />;40}The two things that actually make it fast
Transfer buffers, do not clone them
postMessage clones by default. For a 20MB image buffer that clone is itself a main-thread stall. Passing the ArrayBuffer in the transfer list moves ownership instead — zero copy, but the sender can no longer touch it (that is the point).
worker.postMessage({ buffer }, [buffer]); // 2nd arg = transfer list// `buffer` is now neutered on this side — read `result` from the replyInstantiate the module once
init() compiles the wasm. Doing it per call throws away the JIT warmup and re-runs instantiation. Cache the promise, await it every call — cheap after the first.
When it is worth it, and when it is not
| Situation | Verdict |
|---|---|
| Loop over 10M numbers / pixels, tight math | Worth it — 3–10× and off the main thread |
| Parsing a large binary format (protobuf, media, WASM itself) | Worth it |
| Existing battle-tested C/Rust library (image codecs, SQLite, ffmpeg) | Worth it — do not reimplement it in JS |
| String-heavy work, lots of small allocations | Often not — the JS↔wasm boundary and string marshalling eat the win |
| Occasional work that takes 30ms | Not worth the build complexity — scheduler.yield() or a plain Worker is enough |
WebAssembly is not “faster JavaScript.” It is a way to run code that was never going to be fast in JavaScript, in the one place a browser lets you — and a Worker is what keeps it from freezing the page while it runs.
Found this useful? Pass it on.
Want a custom write-up for your team? Get in touch.
Building something like this?
If a post here maps to a problem on your roadmap, that's usually a good sign we should talk.