Tags: web-dev concept

Web Workers

Date: 2026-08-17


A second JavaScript thread, with no access to the DOM and no shared variables — communication is by copied messages only. That constraint is what makes them safe and what makes them awkward, and it’s why they solve computation problems and not rendering ones.


A web worker is a script running in a separate background thread from the page, communicating with it only by passing messages.

What they actually give you

ASYNC (promises, await)              WEB WORKER

still ONE thread                     a SECOND thread
a long function still blocks         runs genuinely in parallel
"non-blocking" means it yields       the main thread is free throughout
  between operations, not that
  it runs elsewhere

→ solves WAITING                     → solves COMPUTING

This is the distinction that decides whether a worker helps. await doesn’t make a 400ms loop stop blocking — it’s still on the main thread. Only a worker moves the work off it — The Event Loop, Long Tasks and Blocking.

The smallest working form

// main.js
const worker = new Worker('/worker.js');
 
worker.postMessage({ products, filters });      // structured clone: COPIED
 
worker.onmessage = (e) => {
  render(e.data.results);                       // back on the main thread
};
// worker.js — no window, no document, no DOM
self.onmessage = (e) => {
  const { products, filters } = e.data;
  const results = expensiveFilterAndSort(products, filters);   // blocks
  self.postMessage({ results });                               // only HERE
};

The worker has no DOM. It can’t read an element, can’t write one, can’t call getComputedStyle. It has fetch, IndexedDB, WebAssembly, timers and most of the language — everything except the document.

The cost: messages are copied

Data passed between threads is structured-cloned — serialised, copied, deserialised. For large payloads that copy happens on the main thread and can itself cause a stall.

50,000 product objects

postMessage(products)     ~40–80ms of main-thread blocking to clone
                          ← you may have spent more than you saved

Two escapes:

// TRANSFER: hand over ownership. near-instant, but the sender loses access
const buffer = new ArrayBuffer(1024 * 1024);
worker.postMessage(buffer, [buffer]);
// buffer.byteLength is now 0 on this side — it's gone
 
// SHARE: SharedArrayBuffer, genuinely shared memory
// requires cross-origin isolation headers, and reintroduces
// every classic concurrency hazard — Race Conditions

The rule of thumb: a worker pays off when the computation is long and the data crossing the boundary is small. Parsing a 4 MB CSV in a worker and returning 200 summary rows is an excellent trade. Passing 50,000 objects back and forth to save 30ms of work is a loss.

Where they genuinely help in commerce

  • Client-side search and filtering over a large catalogue held in memory — the case where a worker most clearly wins
  • Parsing large responses. JSON.parse on a multi-megabyte payload blocks; doing it in a worker doesn’t
  • Image manipulation before upload — resizing, EXIF stripping — via OffscreenCanvas, which is one of the few rendering-adjacent APIs workers can use
  • Analytics batching and processing, keeping event serialisation off the critical thread — Event Batching and Delivery
  • Cryptographic work, hashing, and anything computational in a checkout flow

Where they don’t help

  • DOM manipulation. The most-wanted use, and permanently unavailable. A slow render is not a worker problem — Reflow and Repaint
  • Network waiting. fetch is already non-blocking on the main thread; a worker adds nothing
  • Short tasks. Worker startup is a few milliseconds plus the script parse, so anything under ~50ms is usually not worth moving
  • Anything needing synchronous access to main-thread state

The relatives

DEDICATED WORKER    one page owns it. the default.
SHARED WORKER       several tabs of the same origin share one instance
SERVICE WORKER      a network proxy, not a computation thread —
                    different purpose entirely
                    — Cache API and Service Workers
WORKLETS            tiny, specialised (audio, paint, layout)

The third row is a different tool entirely — Cache API and Service Workers.

Service workers get confused with web workers constantly. Both are off-main-thread scripts; a service worker exists to intercept network requests and survive page navigations, and using one for computation is the wrong tool.

Practical

  • Use a wrapper library unless the messaging is trivial. Hand-rolled postMessage protocols become an ad-hoc RPC layer with no types and no error handling
  • Handle errors explicitly. An uncaught exception in a worker fires onerror on the worker object and does not surface as a normal error — it will be invisible in your error tracking unless wired up — Error Tracking
  • Terminate workers you’re done with — worker.terminate(). A worker per component that’s never cleaned up is a memory leak with a thread attached — Memory and Long Sessions
  • Measure before and after. The clone cost is easy to underestimate and the win is easy to assume — Interaction to Next Paint
  • Feature-detect and degrade. The synchronous version should still work, just slower — Progressive Enhancement

Where it interacts

  • The Event Loop — the single-thread model workers are the exception to
  • Long Tasks and Blocking — the problem workers solve, and the alternative fixes that are usually cheaper
  • Tasks and Microtasks — yielding on the main thread, which handles many cases without a worker at all
  • Race Conditions — the hazards message-passing avoids, and SharedArrayBuffer reintroduces