QuestionsWeb Performance

Offloading work to a Web Worker

Web WorkersHardWeb Performance

A CPU-heavy computation freezes the UI. When does a Web Worker help, what are its constraints, and how do you communicate with it?

What it tests

Knowing workers run off the main thread, their isolation model, messaging cost, and structured clone.

Approach & answer

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.

Use this technique when

Moving CPU-bound work off the main thread to keep the UI responsive; deciding worker vs. yielding.

Code

// 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);
  }
};

References