Full guide with a live frozen vs smooth UI demo, Comlink setup, transferable objects, and React hook on
onlyWebPro.
JavaScript on the main thread shares one thread with rendering, event handling, and layout. A heavy computation — sorting 200k rows, parsing a 10MB CSV — blocks everything until it finishes. The UI freezes.
Web Workers move that computation to a separate OS thread. The main thread stays completely free.
Why async/await doesn't solve this
Promises and async/await are still single-threaded. They let you defer work so other tasks can run in between — but if the computation itself is CPU-bound, the thread is still blocked while it runs. Web Workers are a separate thread. That's a completely different thing.
The basic pattern
// worker.js — runs in its own thread
self.onmessage = function(event) {
const { n } = event.data;
const result = findNthPrime(n); // heavy computation here
self.postMessage({ result });
};
// main.js — creates and communicates with the worker
const worker = new Worker('worker.js');
worker.onmessage = ({ data }) => console.log(data.result);
worker.postMessage({ n: 8000 });
Comlink — the right way in 2026
Raw postMessage gets messy fast: manual message routing, matching requests to responses, propagating errors. Comlink (1.1kB, Google) wraps all of that and lets you call worker functions as regular async functions.
// heavy.worker.js
import * as Comlink from 'comlink';
Comlink.expose({
async findNthPrime(n) { /* ... */ },
async sortLargeArray(arr) { return [...arr].sort((a, b) => a - b); },
async parseCSV(text) { return text.trim().split('\n').map(r => r.split(',')); }
});
// main.js
import * as Comlink from 'comlink';
const worker = new Worker(new URL('./heavy.worker.js', import.meta.url), { type: 'module' });
const api = Comlink.wrap(worker);
// Feels like a local async call — no postMessage in sight
const prime = await api.findNthPrime(8000);
const sorted = await api.sortLargeArray(hugeArray);
Transferable objects — zero-copy data transfer
By default, postMessage deep-copies data. For a 50MB ArrayBuffer that's ~180ms of copy time — defeating the purpose of the worker. Transferable objects transfer ownership in ~0.1ms regardless of size.
const buffer = new ArrayBuffer(50 * 1024 * 1024); // 50 MB
// ❌ Copy — ~180ms for 50MB
worker.postMessage({ buffer });
// ✅ Transfer — ~0.1ms, buffer unusable on main thread afterward
worker.postMessage({ buffer }, [buffer]);
Use transfer for: ArrayBuffer, ImageBitmap, OffscreenCanvas, streams.
React hook — clean lifecycle management
export function useWorker(workerUrl) {
const workerRef = useRef(null);
const apiRef = useRef(null);
useEffect(() => {
workerRef.current = new Worker(workerUrl, { type: 'module' });
apiRef.current = Comlink.wrap(workerRef.current);
return () => workerRef.current?.terminate(); // always clean up
}, []);
return apiRef;
}
When to use a worker (and when not to)
✅ Use for: sorting/filtering 100k+ rows, parsing large files, image processing, running a search index, encryption/hashing
❌ Don't use for: fetch calls (already async), DOM updates (workers have no DOM access), computations under ~5ms (message overhead isn't worth it)
The full guide has a live demo where you can see a bouncing animation freeze on the main thread and keep running with a worker — same computation, completely different experience.
👉 Read the full guide + live demo
Part 4 — the final post — of the Frontend Performance Series on onlyWebPro.
Have you shipped Web Workers in a production app? What was the use case?
Top comments (0)