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?

