Web Workers

Learn how to offload CPU-intensive work to a Web Worker so it doesn't block the main thread's rendering, how to communicate with a worker via postMessage, and how to manage a worker's lifecycle cleanly inside a React component.

JavaScript in the browser runs on a single main thread that both executes your application code and handles rendering, layout, and input — so a heavy synchronous computation (parsing a large file, running an image filter, computing a complex layout) freezes the UI for as long as it takes to finish, no matter how well the React component around it is written. A Web Worker runs JavaScript on a genuinely separate OS thread with its own memory, letting expensive work happen in the background while the main thread stays free to keep the UI responsive.

Main thread vs. worker thread

A Web Worker is a script that the browser loads and executes in an isolated global context with no access to the DOM, window, or any variables from the page that created it. This isolation is what makes workers safe — two threads can't race to mutate the same DOM node — but it also means a worker can't directly call setState or touch a ref; all communication with the main thread happens through message passing.

React itself only ever runs on the main thread — components, hooks, and the render/commit process are unaffected by, and unaware of, any worker running alongside them. From React's point of view, a worker is just another asynchronous source of data, conceptually similar to a fetch call: you send it a request, and at some point later a message event delivers a result that you feed into state.

Because spinning up a worker has real overhead (loading and parsing a separate script, allocating a thread), workers are worth it specifically for work that would otherwise take long enough to visibly stutter the UI — typically tens of milliseconds or more of synchronous computation. Trivial work is not worth the message-passing overhead and added complexity.

postMessage, transferables, and lifecycle

Communication in both directions goes through postMessage: the main thread calls worker.postMessage(data) to send a task, and the worker calls postMessage(result) from inside its own scope to send results back; each side listens with an onmessage handler (or addEventListener('message', ...)). By default, postMessage copies its argument using the structured clone algorithm — objects, arrays, Maps, and typed arrays are all supported, but functions, DOM nodes, and class instances with methods are not, since only their data can be cloned.

For large binary payloads — a big ArrayBuffer of image or audio data — copying on every message can itself become a bottleneck. Transferable objects solve this: passing an ArrayBuffer in postMessage's second argument (the transfer list) moves ownership of that buffer to the receiving thread instead of copying it, which is nearly instantaneous regardless of size, at the cost that the sending side's reference becomes unusable (its byteLength becomes 0) immediately after the transfer.

A worker, once created with new Worker(url), stays alive until either the page unloads or something calls worker.terminate() — it is not garbage collected just because nothing currently references it, and it is not tied to any component's lifecycle automatically. Inside a React component, a worker used for a specific task is therefore created in a useEffect and terminated in that effect's cleanup function, exactly like a WebSocket or a subscription, so navigating away from the component doesn't leave a thread running forever.

A typed worker hook

The worker file is a completely separate script, bundled and loaded independently — most build tools (Vite, webpack) have a special import syntax like new Worker(new URL('./fibonacci.worker.ts', import.meta.url)).

// fibonacci.worker.ts — runs inside the worker, no DOM access
self.onmessage = (event: MessageEvent<number>) => {
  const n = event.data;
  const result = fib(n);
  self.postMessage(result);
};

function fib(n: number): number {
  return n < 2 ? n : fib(n - 1) + fib(n - 2);
}

export {}; // marks this file as a module for TypeScript

Code Examples

fib(40) computed recursively takes long enough to freeze an unresponsive tab for a noticeable moment; running it in the worker keeps the input field above fully responsive while the computation runs in the background. Expected result: Clicking the button shows 'Computing…' immediately, the text input keeps accepting keystrokes without lag, and the result appears once the worker posts it back.

return () => worker.terminate();

Transferring, rather than cloning, a large ArrayBuffer avoids doubling memory usage and the copy time, which matters for things like large image or audio buffers.

Common mistake: Reading from buffer on the main thread after transferring it — its contents are gone from this side once ownership moves to the worker.

function useFibonacciWorker() {
  const workerRef = useRef<Worker | null>(null);
  const [result, setResult] = useState<number | null>(null);
  const [busy, setBusy] = useState(false);

  useEffect(() => {
    const worker = new Worker(new URL("./fibonacci.worker.ts", import.meta.url));
    worker.onmessage = (e: MessageEvent<number>) => {
      setResult(e.data);
      setBusy(false);
    };
    workerRef.current = worker;
    return () => worker.terminate();
  }, []);

  function compute(n: number) {
    setBusy(true);
    workerRef.current?.postMessage(n);
  }

  return { result, busy, compute };
}

function FibonacciDemo() {
  const { result, busy, compute } = useFibonacciWorker();
  return (
    <div>
      <button onClick={() => compute(40)} disabled={busy}>
        {busy ? "Computing…" : "Compute fib(40)"}
      </button>
      {result !== null && <p>Result: {result}</p>}
      <input placeholder="Type here — stays responsive" />
    </div>
  );
}
function sendBuffer(worker: Worker, buffer: ArrayBuffer) {
  // second argument marks 'buffer' for transfer, not copy
  worker.postMessage({ type: "process", buffer }, [buffer]);
  // buffer.byteLength is now 0 here — ownership moved to the worker
}

Worker communication flow

The main thread stays free to render while the worker computes in parallel; the only coupling between the two is the messages passed across the boundary.

React Main Thread
       │
 postMessage
       ▼
 Web Worker
       │
   result
       ▼
React Main Thread

Important Rules

Create a worker in useEffect and call worker.terminate() in that effect's cleanup to avoid leaking a background thread.

Remember a worker has no access to the DOM, window, or any main-thread variables — all data must cross via postMessage.

Use the transfer list argument of postMessage for large ArrayBuffers to move, not copy, ownership.

Reserve workers for computation heavy enough (roughly tens of milliseconds or more) to justify their setup and messaging overhead.

Keep a worker's own onmessage handler narrowly typed and validate the shape of incoming data, just as with any external message source.

Where this shows up

Client-side image and video processing (filters, compression, thumbnail generation), parsing and validating large CSV or JSON files before display, running search indexing or fuzzy-matching over large datasets, and executing WASM-based computation (like a code sandbox or a crypto library) all lean on Web Workers to keep scrolling and typing smooth while the heavy lifting happens off the main thread.

function Component() { const worker = new Worker("worker.js"); // recreated every render worker.postMessage(data); } useEffect(() => { const worker = new Worker("worker.js"); worker.postMessage(data); return () => worker.terminate(); }, []);

Creating the worker directly in the component body spins up a brand-new thread on every single render and never terminates the old ones, quickly exhausting resources.

worker.postMessage({ buffer }); console.log(buffer.byteLength); // assumes buffer is still usable worker.postMessage({ buffer }, [buffer]); // don't read from buffer on the main thread after this point

Once an ArrayBuffer is transferred, the sending side's reference is detached and unusable; code that keeps reading from it after the transfer will see it as empty.

[true-false] A Web Worker can directly call React's setState or manipulate the DOM. A. True B. False

B. False — A worker runs in an isolated context with no DOM access; it can only communicate with the main thread by posting messages, which the main thread then uses to update React state.

What does passing an ArrayBuffer in postMessage's transfer list (second argument) accomplish? A. It copies the buffer twice for safety B. It moves ownership of the buffer to the receiving thread instead of cloning it, avoiding the copy cost C. It converts the buffer into a string D. It prevents the worker from reading the buffer at all

B. It moves ownership of the buffer to the receiving thread instead of cloning it, avoiding the copy cost — Transferring an ArrayBuffer moves it to the receiving side near-instantly regardless of size, at the cost that the sending side can no longer use it.

Where should a worker created for a component's lifetime be terminated? A. It never needs to be terminated explicitly B. In the cleanup function of the useEffect that created it C. Inside the worker's own onmessage handler D. Automatically by React when the component re-renders

B. In the cleanup function of the useEffect that created it — A worker keeps running until explicitly terminated or the page unloads, so it should be terminated in the creating effect's cleanup to match the component's lifecycle.

Build a CSV-parsing worker: the main thread posts a large CSV string to the worker, the worker parses it into an array of row objects and posts the result back, and the component shows a loading spinner the entire time without the UI freezing. Hint: Keep the parsing logic entirely inside the worker file — the main thread should only ever see the raw string going in and the parsed array coming out.

Try it yourself — there's no single right answer here; compare your result against this lesson's examples.