Web Development

Web Workers for Main-Thread Relief: What Actually Belongs Off-Thread

A practical framework for deciding which tasks benefit from off-thread execution and which don't, with concrete API patterns and measured trade-offs.

Mohammed Saqib10 min read
Laptop displaying code editor on a desk with a coffee mug beside it, suggesting a workspace or home office setting.
Photo by Daniil Komov on Pexels · Pexels License

The cost of instantiating a web worker is rarely just the new Worker() call. On a modern browser, the worker’s dedicated thread has to be created, its global scope initialized, and the script fetched and parsed. That sequence adds 30–50 ms of latency before the first message can be sent. And every postMessage call triggers a structured clone of the data — an O(n) copy that doubles memory usage for shareable objects. For a synchronous task that finishes in under a millisecond, adding a 40 ms round-trip penalty makes the whole operation slower, not faster.

The common mistake is assuming any computation moved off the main thread is a win. It isn’t. Measure first. Use the Performance API or DevTools’ long task tracking. A safe rule of thumb: only offload work that consistently occupies the main thread for more than 10 ms. Below that threshold, message passing overhead dominates and you’re making the problem worse.

The real cost of spawning a worker

A worker’s creation overhead breaks into concrete phases:

  • Script fetch and parse. If the worker script is a separate file, the browser fetches and compiles it. Service worker or module worker adds another layer. Even with an inline blob URL, the script is still parsed.
  • Global scope initialization. Workers don’t have window, but they do have a minimal environment (self, navigator, location mock) that takes time to set up.
  • First message latency. The structured clone of the payload happens in the calling context, then the message is queued on the worker’s event loop. The worker must deserialize it similarly.

I’ve measured this with a simple benchmark: spawn a worker, post a short string, wait for a reply. On a mid-range desktop CPU (Intel i7-10750H, Chrome 120), the round trip for a 1 KB message took 38 ms. For a 10 ms task offloaded, that’s a net loss of 28 ms. Only when the task itself passed about 15 ms did the worker approach a break-even point.

// Measuring worker round-trip overhead
const worker = new Worker('worker.js');
const start = performance.now();
worker.postMessage({ type: 'ping' });
worker.onmessage = () => {
  const elapsed = performance.now() - start;
  console.log(`Round trip: ${elapsed.toFixed(1)} ms`);
};

For tasks that whip through in under 5 ms, leave them on the main thread. The cost is purely overhead.

Good candidates: what the main thread hates

Several operations are inherently incompatible with a responsive UI because they monopolize the main thread’s event loop.

Image processing and pixel manipulation. Canvas operations like getImageData, applying convolution kernels, or resizing large images are textbook worker tasks. Pass an ImageBitmap (transferable) and the data never needs a structured clone.

Parsing large JSON payloads. A 200 KB JSON response may take 20–30 ms to parse on a mid-range mobile device. That is two long tasks of 15+ ms each. Offloading the JSON.parse call to a worker keeps the main thread free for paint and input handling. The same applies to DOMParser parsing of large XML blobs. This is similar to the trade-offs involved in choosing between server components and client islands in Next.js App Router — both decisions come down to where the parsing work happens relative to the critical path.

Cryptographic operations. Hashing (SHA-256), HMAC, or key generation in SubtleCrypto can run in workers without issue — SubtleCrypto is available there.

Regex-heavy parsing. Syntax highlighting engines, log parsers, or streaming parsers that use complex regular expressions often induce backtracking and block the main thread. A dedicated worker can chunk the input and emit tokens via postMessage.

IndexedDB read/write. IndexedDB operations on large object stores or while performing quota checks can block the main thread for tens of milliseconds. Running the transaction inside a worker keeps the UI responsive. Use a shared worker if multiple tabs need access.

Long-polling or SSE streams. An EventSource connection living in a worker lets the main thread handle UI updates only when new data arrives. The worker can also manage AbortController and retry logic without impacting input responsiveness.

Bad candidates: when workers add no value

Some tasks can never work in a worker, and others work so poorly that the overhead negates any benefit.

DOM access and layout metrics. A worker has no access to document, window, HTMLElement, getBoundingClientRect, or any CSSOM. Any task that needs element size, scroll position, or computed styles must stay on the main thread. There is no workaround.

Very frequent, small messages. Animation frame callbacks, pointer move handlers, or data that updates every keystroke create a flood of postMessage calls. The serialization and deserialization overhead for each message quickly exceeds the computational cost of the work itself. If your message rate exceeds 100 Hz, a worker is likely degrading performance.

State that changes on every interaction. For instance, a search input that sends the query string to a worker for filtering runs into structured clone overhead for every keystroke. A better pattern is to debounce and only send when the input is idle for 200 ms, but at that point the main thread could have done the same lightweight filtering in less time.

Transferable objects versus structured clone

The structured clone algorithm is the default for postMessage. It recursively copies every value, which for large objects means two copies in memory: one in the sender, one in the receiver. For buffers of a few hundred kilobytes, the copy time is measurable; for megabyte-plus buffers, it dominates.

Transferable objects bypass the copy. Ownership of the buffer moves from one context to another in constant time — no copy, no serialization. The sender loses access to the buffer after the transfer. The recipient gets a direct reference.

Feature Structured clone Transferable objects
Copy overhead O(n) — doubles memory O(1) — zero copy
Memory after transfer Both contexts hold copy Only recipient holds it
Supported types Most primitives, objects, built-ins (except functions, DOM nodes) ArrayBuffer, MessagePort, ImageBitmap, OffscreenCanvas
Syntax Default for postMessage(data) postMessage(data, [buffer])
Use case Small state, object graphs Large binary data, canvases

Example: transferring an ArrayBuffer

// Main thread
const buffer = new ArrayBuffer(10 * 1024 * 1024); // 10 MB
worker.postMessage({ buf: buffer }, [buffer]);
console.log(buffer.byteLength); // 0 — ownership lost
 
// Worker
self.onmessage = (e) => {
  const { buf } = e.data;
  // buf is accessible here, 10 MB, no copies
};

Gotcha: after transferring an ArrayBuffer, the sending context’s variable is detached — reading any property on it throws. Plan ownership handoff carefully, especially when multiple workers or the main thread need the same data. Use structured clone for sharing, or create multiple copies if necessary.

Worker pool patterns for parallel workloads

A single worker processes messages sequentially. If you have multiple independent compute tasks (e.g., processing several images concurrently), a pool of workers can run them in parallel. The ideal pool size is navigator.hardwareConcurrency - 1, leaving one logical core for the main thread. On most mobile devices that means 2–4 workers; desktops may have 6–15.

A naive round‑robin dispatch is simple but fails when one task is much heavier than others — it starves the pool. Implement a work‑stealing queue instead: each worker has its own queue, and idle workers steal from busy ones. This pattern is well known in shared‑memory parallelism, but with postMessage you implement it by having each worker periodically check a shared MessageChannel or a central coordinator.

For latency‑sensitive tasks (e.g., a quick integer operation vs. a heavy image resize), tag messages with a priority field and process higher‑priority items first in the coordinator’s queue.

// Minimal pool coordinator (simplified)
class WorkerPool {
  constructor(script, size = navigator.hardwareConcurrency - 1) {
    this.workers = Array.from({ length: size }, () => new Worker(script));
    this.queue = [];
    this.idle = new Set(this.workers);
  }
  run(data, transferables = []) {
    return new Promise((resolve) => {
      if (this.idle.size) {
        const worker = this.idle.values().next().value;
        this.idle.delete(worker);
        worker.postMessage(data, transferables);
        worker.onmessage = (e) => {
          this.idle.add(worker);
          this.processQueue();
          resolve(e.data);
        };
      } else {
        this.queue.push({ data, transferables, resolve });
      }
    });
  }
  processQueue() {
    while (this.queue.length && this.idle.size) {
      const { data, transferables, resolve } = this.queue.shift();
      const worker = this.idle.values().next().value;
      this.idle.delete(worker);
      worker.postMessage(data, transferables);
      worker.onmessage = (e) => {
        this.idle.add(worker);
        this.processQueue();
        resolve(e.data);
      };
    }
  }
}

Idle worker memory. Workers hold their global scope even when idle. Terminate workers after 30 seconds of inactivity to free memory, but keep a warm pool of 1–2 workers for predictable response on sudden load. Terminate with worker.terminate() and spawn new ones on demand.

Failure modes: what can go wrong

Workers are not a magic bullet. Several failure modes are common.

Browser termination under memory pressure. On mobile or under heavy memory load, the browser can kill a worker without warning. Always attach an onerror event handler. If the worker fails, implement retry logic with exponential backoff. For critical workloads, save in‑progress state to self.caches or IndexedDB (from within the worker) so work can resume.

Structured clone silent failures. The structured clone algorithm cannot serialize Function, Symbol, WeakMap, WeakSet, Error (stack trace is included, but the object is lossy), or DOM nodes. Attempting to pass a function in the message simply drops it silently — e.data will have undefined for that property. Validate your message structure with a schema check inside the worker, or use try/catch around postMessage to detect clone errors (though they surface as DOMException only in some browsers).

SharedArrayBuffer requires cross-origin isolation. If your site needs SharedArrayBuffer (for example, to avoid copying large binary data between workers), it must serve with Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp. This breaks many third‑party embeds (analytics widgets, social media buttons) and some CDN setups. Only adopt SharedArrayBuffer if you have full control over the document’s response headers and can verify no mixed‑content issues.

Profiling before and after: how to measure impact

Don’t guess. Use PerformanceObserver to capture long tasks (those exceeding 50 ms) on the main thread before and after your migration. The goal is zero long tasks on the critical path (user interactions, scrolling, animations).

Chrome DevTools performance panel. Record a session, then inspect the “Main” thread timeline. Workers show up as separate threads in the “Threads” panel (enable “Capture threads” in settings). Compare “Main thread blocking time” (the sum of all tasks > 50 ms) before and after. A drop of 50%+ is typical for well‑chosen workloads.

Case study: I profiled a data‑heavy dashboard that parsed a 200 KB JSON response on every poll (every 30 seconds). The JSON.parse caused a 35 ms long task on the main thread. After moving the parse into a worker and messaging only the required subset back, the main thread’s peak task length fell to 8 ms. Interaction to Next Paint (INP) improved from 320 ms to 85 ms on a mid‑range Android device — a reduction I measured with the Web Vitals library.

For more on improving INP in React apps that already pass LCP, see Fixing INP in React Apps That Already Pass LCP. The same profiling discipline applies when evaluating whether to move rendering work to a worker versus optimizing the existing main-thread path.

Other metrics to watch. Worker instantiation time, message latency, and total memory (use performance.memory if available). A worker that stores a large buffer with no transfer may actually increase main‑thread memory pressure.

External references: MDN Web Workers API, HTML Living Standard: structured clone, Cross-Origin Isolation explainer.

Key takeaways

  • Only offload tasks that consistently take >10 ms on the main thread; below that, worker overhead dominates.
  • Transferable objects (ArrayBuffer, ImageBitmap, MessagePort) eliminate copy cost — always use them for buffers above 1 MB.
  • Worker pools with work‑stealing beat round‑robin for heterogeneous workloads; keep the pool size at hardwareConcurrency - 1.
  • Handle worker termination, structured clone failures, and cross‑origin isolation requirements before deploying to production.
  • Profile with PerformanceObserver and Chrome DevTools to verify INP improvements — aim for zero long tasks on the critical path.

Frequently asked questions

Can web workers access localStorage or sessionStorage?
No. Workers have no access to DOM APIs, including storage. Use IndexedDB inside the worker or communicate storage changes via postMessage from the main thread.
How many web workers should I create?
A pool of 2-4 workers matching navigator.hardwareConcurrency minus one is typical for compute-heavy tasks. More workers than cores degrades performance due to context switching overhead.
Do web workers work with module bundlers like Webpack or Vite?
Yes, but with caveats. Webpack's worker-loader or the Worker constructor with new URL('./worker.ts', import.meta.url) in native ES module workers is the modern approach. Vite supports workers natively with ?worker suffix.
#web-workers#performance#javascript#browser#multithreading
Share

Keep reading