A CPU-heavy computation freezes the UI. When does a Web Worker help, what are its constraints, and how do you communicate with it?
Knowing workers run off the main thread, their isolation model, messaging cost, and structured clone.
A Web Worker runs JavaScript on a SEPARATE thread, so long computation there doesn't block the main thread that handles input, layout, and paint — the fix for UI freezes caused by pure CPU work (parsing big files, image processing, crypto, heavy data transforms, running a WASM module). When it helps: the work is CPU-bound and self-contained. When it does NOT help: work that's I/O-bound (already async on the main thread) or that must touch the DOM — workers have NO access to the DOM, window, or most page APIs; they get their own global (self), plus fetch, timers, IndexedDB, WebSockets, and importScripts/ESM. Communication is by message passing: postMessage on one side, onmessage on the other. Crucially the data is COPIED via the structured clone algorithm, not shared — so sending a huge object has a real serialization cost that can eat the savings. Two escape hatches: (1) Transferable objects — pass an ArrayBuffer (or OffscreenCanvas, etc.) with a transfer list so ownership MOVES to the worker with zero copy (the sender can no longer use it). (2) SharedArrayBuffer for genuinely shared memory across threads (gated behind cross-origin isolation / COOP+COEP headers, and you must coordinate with Atomics). Practical patterns: keep messages coarse (batch work, don't chat per item), consider a worker pool for parallelism across cores, and use a small RPC/comlink-style wrapper so the async boundary reads like normal calls. Also note OffscreenCanvas lets you do rendering work in a worker. The mental test: 'is this pure computation that's janking the thread, and can I ship its inputs/outputs cheaply?' — if yes, a worker; if it needs the DOM or the transfer cost dominates, keep it on-thread and instead break it into yielding chunks.
Moving CPU-bound work off the main thread to keep the UI responsive; deciding worker vs. yielding.
// main.js
const worker = new Worker(new URL('./worker.js', import.meta.url), { type: 'module' });
worker.onmessage = (e) => console.log('result:', e.data);
// Zero-copy transfer of a big buffer (ownership moves to the worker)
const buf = new ArrayBuffer(50 * 1024 * 1024);
worker.postMessage({ cmd: 'process', buf }, [buf]); // buf now unusable here
// worker.js (no DOM/window here; own global 'self')
self.onmessage = (e) => {
const { cmd, buf } = e.data;
if (cmd === 'process') {
const view = new Uint8Array(buf);
let sum = 0;
for (let i = 0; i < view.length; i++) sum += view[i]; // heavy work, off-thread
self.postMessage(sum);
}
};