41. Multi-threaded architecture patterns: worker pools, Comlink-style RPC
Raw postMessage with manual cmd/result message shapes (§38) doesn't
scale past a couple of message types — real apps use two patterns:
Worker pool — a fixed number of persistent workers pulling tasks from a shared queue, avoiding the real cost of spinning up a new worker per task (worker creation involves a full separate JS realm bootstrap, not free):
class WorkerPool {
#workers; #queue = []; #idle = [];
constructor(scriptUrl, size = navigator.hardwareConcurrency || 4) {
this.#workers = Array.from({ length: size }, () => {
const w = new Worker(scriptUrl);
w.onmessage = (e) => this.#handleResult(w, e.data);
return w;
});
this.#idle = [...this.#workers];
}
run(task) {
return new Promise((resolve) => {
this.#queue.push({ task, resolve });
this.#dispatch();
});
}
#dispatch() {
while (this.#idle.length && this.#queue.length) {
const worker = this.#idle.pop();
const { task, resolve } = this.#queue.shift();
worker.__resolve = resolve;
worker.postMessage(task);
}
}
#handleResult(worker, data) {
worker.__resolve(data);
this.#idle.push(worker);
this.#dispatch();
}
}
RPC-style wrappers (Comlink) eliminate the manual message-shape
boilerplate entirely by proxying method calls transparently over
postMessage:
// worker.js
import * as Comlink from "comlink";
const api = { fib(n) { return n <= 1 ? n : api.fib(n - 1) + api.fib(n - 2); } };
Comlink.expose(api);
// main.js — CALLS THE WORKER LIKE A NORMAL ASYNC FUNCTION, no postMessage visible at all
import * as Comlink from "comlink";
const worker = new Worker("worker.js");
const api = Comlink.wrap(worker);
const result = await api.fib(40); // looks synchronous-ish, actually round-trips via postMessage
Real-world example. Figma, Photoshop's web version, and FFmpeg.wasm's browser build all use worker pools for exactly this reason: image/video processing is CPU-bound and would otherwise freeze the entire UI (including scroll, clicks, and rendering) for the duration of the computation.