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.

Nov 18, 2025/12 min readPerformance
ShareY
WebAssembly for Heavy Compute, Off the Main Thread

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

src/lib.rs
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}
build it
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
Tip

--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

app/wasm/image.worker.ts
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

components/image-editor.tsx
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).

raw postMessage, if you skip Comlink
worker.postMessage({ buffer }, [buffer]); // 2nd arg = transfer list// `buffer` is now neutered on this side — read `result` from the reply

Instantiate 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

SituationVerdict
Loop over 10M numbers / pixels, tight mathWorth 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 allocationsOften not — the JS↔wasm boundary and string marshalling eat the win
Occasional work that takes 30msNot 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.

All posts

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.