Guide

Web Workers explained

JavaScript in the browser runs on a single main thread that also paints the page, handles clicks, and runs your React components. A 200 ms JSON parse or image resize is not "async" in the sense users care about — it still blocks input until the event loop finishes the task. Web Workers are the standard way to run JavaScript on a separate thread so heavy CPU work does not freeze the UI. This guide covers dedicated workers, the postMessage protocol, transferable buffers, worker pools, how workers differ from service workers, and when they actually help Interaction to Next Paint (INP).

What a Web Worker is

A Web Worker is a background thread with its own global scope. It runs the same JavaScript engine (V8 in Chrome) but has no access to the DOM, window, or most browser APIs tied to the visible page. You spawn a worker from the main thread, send it data, it computes, and sends results back.

Workers are ideal for CPU-bound tasks: parsing large JSON or CSV, compressing images, running cryptography, pathfinding, physics simulation chunks, or preprocessing ML tensors. They are not a substitute for network I/O — fetch is already non-blocking on the main thread. Do not spawn a worker to await an API call; that adds overhead with no gain.

Three worker types exist in the platform, and confusing them causes architecture mistakes:

  • Dedicated workers — one-to-one with the script that created them. This is what most apps need.
  • Shared workers — shared across multiple tabs from the same origin. Rare; limited browser support and tooling.
  • Service workers — network proxies for caching and push. See our service workers guide; they are not for general computation.

Creating a dedicated worker

The simplest pattern loads a separate script file:

// main.js
const worker = new Worker('/workers/parser.js');

worker.onmessage = (event) => {
  console.log('Parsed rows:', event.data.rows);
};

worker.onerror = (err) => {
  console.error('Worker failed:', err.message);
};

worker.postMessage({ csvText: largeString });
// workers/parser.js
self.onmessage = (event) => {
  const { csvText } = event.data;
  const rows = csvText.split('\n').map(line => line.split(','));
  self.postMessage({ rows });
};

Modern bundlers (Vite, webpack 5, Parcel) also support inline workers via new Worker(new URL('./worker.js', import.meta.url), { type: 'module' }). Module workers can use import and share code with the main bundle — useful when the worker needs shared types or utility functions.

Workers inherit the same-origin policy: the worker script must be served from the same origin as the page, or include proper CORS headers if loaded cross-origin. Blob URLs and data URLs work for quick experiments but are harder to debug and cache.

postMessage and structured cloning

Communication is asynchronous and copy-based by default. When you call postMessage, the browser serializes the payload using the structured clone algorithm — a deep copy that handles most JavaScript types (objects, arrays, Dates, Maps, ArrayBuffers) but not functions, DOM nodes, or symbols.

Copying a 50 MB ArrayBuffer on every message can cost more than the computation you offloaded. For large binary data, use transferable objects:

const buffer = new ArrayBuffer(50 * 1024 * 1024);
worker.postMessage({ buffer }, [buffer]);
// Main thread no longer owns buffer — zero-copy handoff

After transfer, the sender's reference is detached. Plan ownership carefully: either the main thread or the worker holds the buffer, not both. For two-way pipelines, allocate fresh buffers or use a ring of reusable views.

For complex apps, define a small message protocol with a type field ({ type: 'PARSE', id: 42, payload: ... }) so one worker can handle multiple operations and replies can be correlated. Avoid sending entire application state on every message.

Worker pools and lifecycle

Spawning a worker has startup cost — parsing and compiling the script, creating the thread. For bursty workloads (user uploads ten files), a worker pool of two to four persistent workers outperforms create-destroy per task.

A minimal pool queues tasks and assigns them to idle workers:

  1. Initialize N workers at app start (often navigator.hardwareConcurrency - 1).
  2. Push tasks into a queue with resolve/reject callbacks.
  3. When a worker finishes, dequeue the next task and postMessage to that worker.
  4. On page unload, call worker.terminate() on each instance.

Do not create dozens of workers — each consumes memory and competes for CPU cores. Mobile devices may expose four cores but throttle background threads aggressively. Profile on real hardware; desktop assumptions mislead.

Long-lived workers can leak memory if closures accumulate results. Restart workers periodically in very long sessions (dashboards open for days) or after processing exceptionally large payloads.

What workers cannot do

Limitations drive architecture decisions:

  • No DOM — cannot call document.querySelector, update React state directly, or touch Canvas elements on the main page. Return data; let the main thread render.
  • Limited APIs — no localStorage (use IndexedDB via async APIs in the worker, or message storage requests to main). Workers can use fetch, WebSocket, crypto.subtle, and importScripts in classic workers.
  • Serialization tax — small objects are fine; huge graphs or functions cannot cross the boundary.
  • Debugging friction — breakpoints live in a separate DevTools context. Name workers with a comment header for easier identification.

For near-native compute (video codecs, game physics, cryptography at scale), pair workers with WebAssembly. WASM modules run inside workers, giving C/Rust performance without blocking the main thread. The main thread stays a thin coordinator: input events in, rendered frames out.

Workers vs async/await

Developers sometimes wrap worker calls in Promises and await them from the main thread. That is fine ergonomically — but the main thread still waits for the reply message, which is cheap. The win is that while the worker computes, the main thread processes input events and paints frames.

async/await alone does not parallelize CPU work. An async function that loops ten million times still monopolizes the main thread until it yields — and tight loops never yield. Workers are the fix when profiling shows long tasks on the main thread in Performance panel recordings.

Scheduler.postTask and requestIdleCallback can break up small chunks of work without threads, but they do not add cores. Use them for deferrable work under 50 ms; use workers when a single task exceeds ~100 ms on mid-tier mobile.

Performance and INP

Google's Interaction to Next Paint measures how long after a tap or keypress the browser presents the next frame. Main-thread long tasks during that window inflate INP even if your average load time looks fine.

Workers help INP when:

  • User interaction triggers heavy computation (filtering 100k table rows, re-encoding an image).
  • Background sync processes data while the user scrolls or types.
  • Games run simulation ticks off-thread and only send positions to Canvas each frame.

Workers do not help when the bottleneck is layout, style recalc, or paint — those are main-thread only. Pair worker offloading with CRP optimizations for pages that feel sluggish despite idle CPU.

Security considerations

Workers run with the same origin privileges as the page that created them. A worker can fetch authenticated endpoints if cookies are included — treat worker code as same-trust-level as main-thread code. Content Security Policy must allow worker scripts: worker-src 'self' (or blob: if using inline workers).

Do not postMessage secrets (private keys, session tokens) to workers unless necessary. Third-party worker scripts are as dangerous as third-party main-thread scripts — supply-chain compromise in a npm worker dependency is full account compromise in a wallet-connected dApp.

Common mistakes

  • Worker for every small task — message overhead exceeds savings for <5 ms work.
  • Copying megabyte buffers — use transferables or SharedArrayBuffer (with cross-origin isolation headers).
  • Updating UI from worker — impossible; always route results through onmessage on main.
  • Ignoring termination — orphaned workers keep running after SPA route changes unless explicitly terminated.
  • Assuming parallelism on one core — two workers on a dual-core phone still time-slice; measure.

Production checklist

  • Profile first — confirm main-thread long tasks before adding worker complexity.
  • Start with one dedicated worker; add a pool only when queueing is proven necessary.
  • Define a typed message protocol with correlation IDs for request/response pairs.
  • Use transferables for ArrayBuffer, ImageBitmap, and MessagePort payloads.
  • Bundle workers with type: 'module' for tree-shaking and shared imports.
  • Handle onerror and timeouts — workers can throw on bad input silently from the main thread's perspective.
  • Call terminate() on route teardown and after unrecoverable errors.
  • Test on mid-tier Android — INP regressions show up there first.
  • Document CSP worker-src requirements in deployment config.

Key takeaways

  • Web Workers run JavaScript on a background thread with no DOM access.
  • postMessage copies data by default; transferables enable zero-copy handoff of buffers.
  • Use workers for CPU-bound work, not for network I/O or DOM updates.
  • Worker pools amortize startup cost; size to hardwareConcurrency, not task count.
  • Pair workers with WASM for compute-heavy modules; keep main thread as coordinator.
  • Workers improve INP when interactions trigger long main-thread tasks — profile to verify.

Related reading