Using Web Workers in React with Hooks

React’s rules and a worker’s rules disagree on one point: React expects effects to be cheap, repeatable and disposable, and a thread is none of those things. Everything below follows from keeping the thread out of the reconciler’s reach.

This guide implements the component-side half of Framework Integration Patterns, part of the Web Workers Architecture & Communication reference. The worker is created once per application; hooks only borrow it.


The Hook, Complete

// worker-service.ts — outside React; created lazily, disposed on HMR
import * as Comlink from 'comlink';
import type { StatsApi } from './stats-worker';

let api: Comlink.Remote<StatsApi> | null = null;

export function stats(): Comlink.Remote<StatsApi> {
  if (!api) {
    const worker = new Worker(new URL('./stats-worker.ts', import.meta.url), { type: 'module' });
    api = Comlink.wrap<StatsApi>(worker);
    if (import.meta.hot) import.meta.hot.dispose(() => { worker.terminate(); api = null; });
  }
  return api;
}
// useComputed.ts — the reusable hook
import { useEffect, useRef, useState } from 'react';
import { stats } from './worker-service';

type State<T> =
  | { status: 'idle' }
  | { status: 'pending' }
  | { status: 'done'; value: T }
  | { status: 'error'; error: Error };

export function useHistogram(series: Float64Array | null, bins: number) {
  const [state, setState] = useState<State<Uint32Array>>({ status: 'idle' });
  const runId = useRef(0);

  useEffect(() => {
    if (!series) { setState({ status: 'idle' }); return; }

    const id = ++runId.current;          // monotonic: only the newest run may write
    setState({ status: 'pending' });

    stats()
      .histogram(series, bins)
      .then((value) => { if (id === runId.current) setState({ status: 'done', value }); })
      .catch((error: Error) => { if (id === runId.current) setState({ status: 'error', error }); });

    return () => { runId.current++; };    // invalidate on unmount or dependency change
  }, [series, bins]);

  return state;
}
// Histogram.tsx — the consumer
export function Histogram({ series }: { series: Float64Array | null }) {
  const result = useHistogram(series, 64);

  if (result.status === 'pending') return <Skeleton />;
  if (result.status === 'error') return <ErrorNote error={result.error} />;
  if (result.status === 'idle') return null;
  return <BarChart counts={result.value} />;
}

Three properties make this safe under React 18 and 19: the thread lives outside the component tree, the effect is idempotent, and every write to state is gated on a run id that only the newest effect owns.

What StrictMode's double mount does to each ownership model Two sequences of the same React StrictMode development behaviour: mount, unmount, mount again. In the upper sequence the worker is constructed inside the effect, so the first mount starts a thread, the unmount terminates it, and the second mount starts another — paying construction twice and, if the cleanup is missing, leaking the first thread for the life of the page. In the lower sequence the effect only issues a request against a singleton, so the first mount sends a request, the unmount invalidates it by bumping a run identifier, and the second mount sends a second request; one thread exists throughout and only the newest result is allowed to write to state. mount → unmount → mount, in development worker in the effect new Worker 5–15 ms, 2–5 MB terminate() new Worker again paid twice and if the cleanup is missing, the first thread survives for the life of the page singleton + run id one worker, alive across all three phases request id 1 id bumped — 1 is stale request id 2 wins the double mount costs one extra message, and the stale reply is dropped on arrival StrictMode is not the problem it exposes: an effect that starts a thread is not idempotent.
The lower row is also what a fast typist produces in production — StrictMode simply makes the bug reproducible on the first render instead of the fiftieth keystroke.

Line-by-Line

stats() is called inside the effect, not at module scope. That keeps construction out of server rendering and off the initial script execution. Calling it in the component body would also work in the browser, but it runs during render — a phase React may abandon or replay — so an effect is the correct place for anything with a side effect.

runId is a useRef, not state. Bumping it must not trigger a render, and it must survive re-renders. A useState counter would loop; a local variable would reset on every render.

The cleanup bumps the id rather than cancelling. The promise cannot be cancelled, so invalidation is the honest model: the work continues, the result is discarded. When the work is expensive enough that continuing matters, add a real cancellation method to the worker API and call it from the cleanup as well.

series and bins are the only dependencies, and series must be stable. If the parent creates the array inline (<Histogram series={new Float64Array(raw)} />), the identity changes every render, the effect re-runs every render, and the worker recomputes forever. Memoise the array where it is produced, or key it by something stable such as a dataset id.

Every branch of the returned state is rendered. A worker-backed value is asynchronous by nature, so the component must handle pending and error states explicitly — the discriminated union makes forgetting one a compile error.


Sharing One Job Between Components

When two distant components need the same result — a progress bar in a header, a chart in the body — passing state down means either prop drilling or a context that re-renders everything. useSyncExternalStore subscribes each consumer directly to a store that lives beside the worker service:

// job-store.ts — a minimal external store, framework-agnostic
type Snapshot = { status: 'idle' | 'pending' | 'done'; progress: number; value?: Uint32Array };

let snapshot: Snapshot = { status: 'idle', progress: 0 };
const listeners = new Set<() => void>();

export const jobStore = {
  subscribe(listener: () => void) { listeners.add(listener); return () => listeners.delete(listener); },
  getSnapshot(): Snapshot { return snapshot; },
  set(next: Snapshot) { snapshot = next; listeners.forEach((l) => l()); },
};
// Any component, any depth — no provider, no prop drilling
import { useSyncExternalStore } from 'react';
import { jobStore } from './job-store';

export function ProgressBadge() {
  const { status, progress } = useSyncExternalStore(jobStore.subscribe, jobStore.getSnapshot);
  return status === 'pending' ? <progress value={progress} /> : null;
}

The service writes into jobStore from its progress callback and again on completion. Two rules keep this correct: getSnapshot must return a stable object when nothing changed — returning a fresh literal each call causes an infinite render loop — and the server snapshot (the third argument, omitted here) must be supplied for server-rendered apps.


Suspense and Transitions

React 18’s concurrent features interact with worker results in two useful ways.

useTransition for non-urgent recomputation. When a slider drives a recomputation, mark the state update as a transition so React keeps the previous chart interactive while the new one is pending instead of blocking on the re-render:

const [isPending, startTransition] = useTransition();

function onBinsChange(next: number) {
  setBins(next);                                  // urgent: the input must feel instant
  startTransition(() => setPendingBins(next));    // non-urgent: the chart may lag
}

This does nothing for the worker’s own latency — the computation takes what it takes — but it prevents the arrival of a result from making an unrelated input stutter.

Suspense needs a cached promise, not a hook. Suspense works by throwing a promise during render, which means the promise must be stable across render attempts. A promise created in the component body is a new promise each time and suspends forever. Cache by key:

const cache = new Map<string, Promise<Uint32Array>>();

export function histogramResource(key: string, series: Float64Array, bins: number) {
  if (!cache.has(key)) cache.set(key, stats().histogram(series, bins));
  return cache.get(key)!;
}

Then use(histogramResource(id, series, 64)) in a component wrapped in <Suspense>. Remember to evict entries, or the cache pins every result ever computed — including the Float64Arrays inside them.

Where each piece lives: module scope, React tree, worker thread Three layers. Module scope holds the lazily constructed worker, the Comlink proxy and any external store, and it survives every render and remount. The React tree holds hooks that issue requests, guard stale results with a run identifier and render the pending, error and done branches; it is recreated freely. The worker thread holds the computation and its warm state such as parsed data and compiled WebAssembly. Arrows show requests flowing down from hooks through the module singleton to the worker, and results flowing back up into either component state or the external store. Three lifetimes, stacked React tree — recreated freely hooks issue requests · run id drops stale replies · pending, error and done branches render Module scope — one per page load lazy worker + proxy · external store for shared jobs · disposed only on hot replacement Worker thread — one per session the computation · warm state: parsed data, compiled WebAssembly, caches request result Nothing in the top layer may create or destroy anything in the bottom one.
Read the diagram as an ownership rule: arrows may cross layers, lifetimes may not.

Testing a Worker-Backed Hook

A hook that talks to a real thread cannot be tested in jsdom, because Worker is undefined there. Two approaches work, and they answer different questions.

Swap the service, test the hook. The hook’s contract is “issue a request, ignore stale replies, expose four states”. That is testable with a fake service in any environment:

// The hook imports `stats()` from the service module — mock that one function.
vi.mock('./worker-service', () => ({
  stats: () => ({ histogram: vi.fn().mockResolvedValue(new Uint32Array([1, 2, 3])) }),
}));

it('ignores a result that arrives after the dependency changed', async () => {
  const { rerender, result } = renderHook(({ s }) => useHistogram(s, 64), {
    initialProps: { s: seriesA },
  });
  rerender({ s: seriesB });                    // invalidates run 1
  await waitFor(() => expect(result.current.status).toBe('done'));
  expect(result.current.value).toBe(resultForB);   // never A's
});

Run the real worker, test the integration. Once per worker API, in a browser-backed runner, assert that every method actually crosses the boundary and returns the declared shape. That catches cloneability and exposure-timing problems the mock cannot see; the runner setup is in Unit Testing Worker Modules with Vitest.

Splitting the two keeps the fast suite fast: hook behaviour is pure logic and runs in milliseconds, while the handful of boundary tests pay for a browser once.


What each phase of the hook is responsible for Four phases. On mount the effect issues a request and takes a run identifier. While pending the component renders a skeleton and ignores anything from an older run. On settle the newest run writes state and everything else is discarded. On unmount the identifier is bumped so a late reply cannot reach a component that is gone. One request, four responsibilities mount issue the request take a run id pending render a skeleton older runs ignored settle newest run writes others discarded unmount bump the run id late replies dropped The thread is untouched in all four phases — only the subscription changes. Each step is cheap; skipping one is what makes the next expensive.
Read it as a contract: the hook owns the request, never the worker.

Gotchas

Terminating in a cleanup you did not write. A shared hook used by two components will run two cleanups; if either terminates the singleton, the other component’s in-flight call never settles. Only the module owns terminate().

Effects that depend on the service object. Putting stats() in a dependency array re-runs the effect whenever the module returns a new proxy — which happens after a hot replacement. Call the accessor inside the effect body and keep the array to real inputs.

useMemo is not a cache for workers. useMemo(() => new Worker(...), []) looks like a singleton but React may discard memoised values at any time, and StrictMode double-invokes the factory. The result is an occasionally leaked thread that no test reproduces.

A dependency array containing an object literal. useEffect(..., [{ bins }]) re-runs every render. The lint rule catches most cases; the ones it misses are usually a useCallback whose own dependencies are unstable.

Storing a huge typed array in useState. React does not proxy or clone state, so this is cheap in itself — but every consumer of that state re-renders when it changes, and a component that maps over 200,000 elements during render undoes the offloading. Keep the array in a ref or an external store, put only a version number in state, and render from a virtualised window.

Results that arrive after navigation. Route changes unmount without cancelling. The run-id guard covers correctness, but if the job is expensive the CPU is still spent — worth a real cancel path on any job over ~200 ms.


Performance Note

The measurable win of singleton ownership shows up on remount, not first render. On a 2023 laptop, constructing a module worker whose script pulls in a 300 kB parser costs 11 ms of main-thread time and about 3.2 MB of memory; a request against an already-running worker costs 0.2 ms and nothing. A dashboard that remounts twelve cards on every filter change therefore saves roughly 130 ms of main-thread blocking per interaction by moving ownership up one level — larger than most of the render optimisations that would otherwise be attempted first.

Frequently Asked Questions

Why does my worker restart on every keystroke?
The worker is being constructed inside a component body, or inside a useEffect whose dependency array contains a value that changes each render — an inline object, an arrow function, or an array literal. Every render then produces a new dependency identity, the cleanup terminates the old thread and the effect starts a new one. Construct the worker outside React entirely, in a module singleton, and let the effect issue requests only.
Do I need useSyncExternalStore, or is useState enough?
useState is enough when a result belongs to one component. Reach for useSyncExternalStore when several components must observe the same job — a progress bar in a header and a table in the body, say — because it subscribes them all to one external source without prop drilling and without the tearing that a shared useState in a context provider can produce during concurrent rendering.

See also