ESC

Type to search the knowledge base.

Web Workers Overview

Move CPU work off the main thread — dedicated workers, messaging, transferables, limits, and when workers aren’t worth it.

advanced3 min read
  • javascript
  • web-workers

Long JavaScript on the main thread freezes input and janks frames. Web Workers run scripts in parallel threads with no DOM access. You talk via message passing (structured clone / transfer).

// main.js
const worker = new Worker(new URL('./heavy.js', import.meta.url), {
  type: 'module',
});

worker.postMessage({ type: 'sum', nums: bigArray });
worker.onmessage = (e) => {
  console.log('result', e.data);
};
worker.onerror = (e) => console.error(e.message);

// later
worker.terminate();
// heavy.js
self.onmessage = (e) => {
  const { type, nums } = e.data;
  if (type === 'sum') {
    const total = nums.reduce((a, b) => a + b, 0);
    self.postMessage({ type: 'sum', total });
  }
};

What workers can / can’t do

Can Cannot
CPU-heavy JS, crypto, parse Touch window / DOM
fetch, timers, IndexedDB (dedicated) Assume shared memory without SAB
Import scripts / modules Synchronously block main for free

OffscreenCanvas exists for some graphics paths — special case, not free DOM.

Message cost

// structured clone of huge arrays costs CPU + memory
worker.postMessage(hugeObject);

// transfer ArrayBuffer ownership — zero-copy
worker.postMessage(buf, [buf]);
// buf detached in sender

Design protocols with small messages or transferables. Don’t post the entire app state every frame.

Module workers vs classic

new Worker('classic.js'); // importScripts inside
new Worker(url, { type: 'module' }); // import/export, better bundler fit

Vite/webpack provide new URL(..., import.meta.url) patterns so hashes resolve.

SharedWorker / Service Worker (names only)

  • SharedWorker — shared among tabs of an origin (limited support/use).
  • Service Worker — network proxy / offline; different lifecycle.

Don’t confuse with dedicated workers for compute.

When not to use a worker

  • Work is tiny (message overhead dominates)
  • You need immediate DOM measurement
  • You’re only waiting on network (async on main is enough)

Workers shine for image processing, parsing large JSON/CSV, encryption, physics, syntax highlighting.

Error and lifecycle hygiene

useEffect(() => {
  const w = new Worker(new URL('./w.js', import.meta.url), { type: 'module' });
  w.onmessage = handle;
  return () => w.terminate();
}, []);

Leaking workers keeps threads and memory alive across SPA navigations.

Interview answer (out loud)

“Dedicated workers run JS off the main thread without DOM. We communicate with postMessage using structured clone or transferable buffers. Good for CPU-bound work; bad for tiny tasks or DOM. I terminate on unmount and prefer module workers with bundler URL resolution.”

Pooling

Creating a worker per task has startup cost. For lots of similar jobs, keep a small pool and a task queue:

// sketch: worker free → post next job; onmessage → resolve promise map by id

Libraries (comlink) reduce boilerplate for RPC-style APIs over postMessage.

Cross-origin workers

Worker scripts are same-origin by default with classic paths; module workers and CDN scripts need correct CORS. Prefer same-origin bundled workers for simplicity.

Debugging

Chrome DevTools lists workers under the page; breakpoints work inside worker files. Log with a [worker] prefix — interleaved main/worker logs confuse otherwise.

Further reading

Related guides