Type something to search...
Web Workers in React: Offloading Heavy Computation

Web Workers in React: Offloading Heavy Computation

JavaScript in the browser runs on a single main thread, and that thread is also responsible for handling clicks, running React, and painting the screen. When you parse a 20 MB CSV, run a fuzzy search over 100,000 records, resize an image, or compute statistics for a large dataset, the main thread is busy. Until it's done, the page can't respond. Buttons ignore clicks, inputs swallow keystrokes, and animations freeze.

React's concurrent features help when the slow part is rendering, because React can split rendering into small pieces. They can't help when the slow part is your own function that runs for 800 milliseconds without yielding. That work needs to move somewhere else.

Web Workers run JavaScript on a separate thread. The main thread stays free for UI while the worker crunches numbers in the background. This post shows how to create workers in a Vite + React project, wrap them in a typed hook, use Comlink for a cleaner API, transfer large data efficiently, and cancel stale work.

How Web Workers Work

A worker is a separate JavaScript environment with its own global scope and event loop. It runs in parallel with the main thread, on another CPU core when one is available.

The two sides communicate only through messages:

  • The main thread calls worker.postMessage(data), and the worker receives it in a message event.
  • The worker calls self.postMessage(result), and the main thread receives it in a message event.

Data passed in messages is copied using the structured clone algorithm. Plain objects, arrays, strings, numbers, Map, Set, Date, typed arrays, and Blob all work. Functions, DOM nodes, and class instances with methods do not.

Workers also have limits:

  • No DOM access. No document, no window, no React components. Workers compute, the main thread renders.
  • Available APIs include fetch, timers, IndexedDB, WebSocket, crypto, and OffscreenCanvas.
  • Startup has a cost. Creating a worker takes a few milliseconds and some memory, so reuse workers instead of creating one per task.

Creating a Worker in Vite

Vite supports workers out of the box with the standard new URL(..., import.meta.url) pattern. The worker file can be TypeScript and can import other modules.

Here's a worker that finds prime numbers, a deliberately CPU-heavy task:

// src/workers/primes.worker.ts
type PrimeRequest = { id: number; limit: number };
type PrimeResponse = { id: number; primes: number; ms: number };

function countPrimes(limit: number): number {
  const sieve = new Uint8Array(limit + 1);
  let count = 0;
  for (let i = 2; i <= limit; i++) {
    if (sieve[i] === 0) {
      count++;
      for (let j = i * i; j <= limit; j += i) sieve[j] = 1;
    }
  }
  return count;
}

self.onmessage = (event: MessageEvent<PrimeRequest>) => {
  const { id, limit } = event.data;
  const start = performance.now();
  const primes = countPrimes(limit);
  const response: PrimeResponse = { id, primes, ms: performance.now() - start };
  self.postMessage(response);
};

And a component that uses it:

// src/PrimeCounter.tsx
import { useEffect, useRef, useState } from "react";

type Result = { id: number; primes: number; ms: number };

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

  useEffect(() => {
    const worker = new Worker(
      new URL("./workers/primes.worker.ts", import.meta.url),
      { type: "module" }
    );
    worker.onmessage = (event: MessageEvent<Result>) => {
      setResult(event.data);
      setBusy(false);
    };
    workerRef.current = worker;
    return () => worker.terminate();
  }, []);

  function run() {
    setBusy(true);
    workerRef.current?.postMessage({ id: Date.now(), limit: 50_000_000 });
  }

  return (
    <div>
      <button onClick={run} disabled={busy}>
        {busy ? "Counting..." : "Count primes up to 50 million"}
      </button>
      {result && (
        <p>
          Found {result.primes.toLocaleString()} primes in{" "}
          {Math.round(result.ms)} ms
        </p>
      )}
      <input placeholder="Try typing while it runs" />
    </div>
  );
}

Click the button and type in the input at the same time. The input stays responsive, because the main thread isn't doing the work. Run the same countPrimes directly in the click handler and the page freezes for the whole calculation.

The new URL(..., import.meta.url) part is important. It tells Vite to bundle the worker file as a separate chunk and gives you the correct URL in both dev and production. The { type: "module" } option lets the worker use import statements.

To get correct types inside the worker file, add the WebWorker library to a dedicated tsconfig, or put a triple-slash reference at the top of the worker:

/// <reference lib="webworker" />

Cleaning Up

The effect's cleanup calls worker.terminate(), which stops the worker immediately and frees its memory. In development, Strict Mode runs effects twice, so you'll see a worker created, terminated, and created again on mount. That's expected and confirms your cleanup works.

A Reusable, Typed Worker Hook

Wiring up onmessage and refs in every component gets repetitive. A small hook can turn a worker into a promise-based function:

// src/hooks/useWorker.ts
import { useCallback, useEffect, useRef } from "react";

type Pending<Res> = {
  resolve: (value: Res) => void;
  reject: (reason: unknown) => void;
};

export function useWorker<Req, Res>(createWorker: () => Worker) {
  const workerRef = useRef<Worker | null>(null);
  const pendingRef = useRef(new Map<number, Pending<Res>>());
  const nextId = useRef(0);

  useEffect(() => {
    const worker = createWorker();
    const pending = pendingRef.current;

    worker.onmessage = (event: MessageEvent<{ id: number; result?: Res; error?: string }>) => {
      const { id, result, error } = event.data;
      const entry = pending.get(id);
      if (!entry) return;
      pending.delete(id);
      if (error) entry.reject(new Error(error));
      else entry.resolve(result as Res);
    };

    worker.onerror = (event) => {
      pending.forEach((entry) => entry.reject(new Error(event.message)));
      pending.clear();
    };

    workerRef.current = worker;
    return () => {
      worker.terminate();
      pending.forEach((entry) => entry.reject(new Error("Worker terminated")));
      pending.clear();
    };
    // createWorker should be a stable module-level function
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  return useCallback((payload: Req) => {
    return new Promise<Res>((resolve, reject) => {
      const worker = workerRef.current;
      if (!worker) return reject(new Error("Worker not ready"));
      const id = nextId.current++;
      pendingRef.current.set(id, { resolve, reject });
      worker.postMessage({ id, payload });
    });
  }, []);
}

Each request gets an ID, so multiple calls can be in flight and each promise resolves with the right answer. The worker side follows a matching protocol:

// src/workers/stats.worker.ts
/// <reference lib="webworker" />

export type StatsRequest = number[];
export type StatsResult = { mean: number; median: number; stdDev: number };

function computeStats(values: number[]): StatsResult {
  const sorted = [...values].sort((a, b) => a - b);
  const n = sorted.length;
  const mean = sorted.reduce((sum, v) => sum + v, 0) / n;
  const median =
    n % 2 ? sorted[(n - 1) / 2] : (sorted[n / 2 - 1] + sorted[n / 2]) / 2;
  const variance = sorted.reduce((sum, v) => sum + (v - mean) ** 2, 0) / n;
  return { mean, median, stdDev: Math.sqrt(variance) };
}

self.onmessage = (event: MessageEvent<{ id: number; payload: StatsRequest }>) => {
  const { id, payload } = event.data;
  try {
    self.postMessage({ id, result: computeStats(payload) });
  } catch (err) {
    self.postMessage({ id, error: err instanceof Error ? err.message : String(err) });
  }
};

Using it in a component:

import { useState } from "react";
import { useWorker } from "./hooks/useWorker";
import type { StatsRequest, StatsResult } from "./workers/stats.worker";

const createStatsWorker = () =>
  new Worker(new URL("./workers/stats.worker.ts", import.meta.url), {
    type: "module",
  });

export function StatsPanel() {
  const computeStats = useWorker<StatsRequest, StatsResult>(createStatsWorker);
  const [stats, setStats] = useState<StatsResult | null>(null);

  async function handleClick() {
    const values = Array.from({ length: 2_000_000 }, () => Math.random() * 100);
    setStats(await computeStats(values));
  }

  return (
    <div>
      <button onClick={handleClick}>Compute stats</button>
      {stats && (
        <dl>
          <dt>Mean</dt>
          <dd>{stats.mean.toFixed(2)}</dd>
          <dt>Median</dt>
          <dd>{stats.median.toFixed(2)}</dd>
          <dt>Std dev</dt>
          <dd>{stats.stdDev.toFixed(2)}</dd>
        </dl>
      )}
    </div>
  );
}

import type is erased at compile time, so importing types from the worker file doesn't pull the worker's code into the main bundle. If you write a lot of hooks like this, Building Your Own Custom Hooks in React has more on designing their APIs.

Using Comlink for a Simpler API

Hand-written message protocols get tedious. Comlink, a tiny library from the Chrome team, makes a worker look like a regular async object:

npm install comlink
// src/workers/search.worker.ts
import { expose } from "comlink";

type Item = { id: number; name: string };
let index: Item[] = [];

const api = {
  load(items: Item[]) {
    index = items;
  },
  search(query: string, limit = 20) {
    const q = query.toLowerCase();
    return index.filter((item) => item.name.toLowerCase().includes(q)).slice(0, limit);
  },
};

export type SearchApi = typeof api;
expose(api);
// src/useSearchWorker.ts
import { useEffect, useRef } from "react";
import { wrap, type Remote } from "comlink";
import type { SearchApi } from "./workers/search.worker";

export function useSearchWorker() {
  const apiRef = useRef<Remote<SearchApi> | null>(null);

  useEffect(() => {
    const worker = new Worker(
      new URL("./workers/search.worker.ts", import.meta.url),
      { type: "module" }
    );
    apiRef.current = wrap<SearchApi>(worker);
    return () => {
      apiRef.current = null;
      worker.terminate();
    };
  }, []);

  return apiRef;
}

Now calling the worker is just await apiRef.current?.search("lamp"). Every method returns a promise, and the types come straight from the worker file. Comlink handles message IDs, errors, and serialization for you.

Notice the worker keeps index in its own memory. You send the dataset once with load, and each search only sends a short query string. Keeping state inside a long-lived worker avoids copying large data on every request.

Transferring Large Data Without Copying

Structured cloning copies data. For a 50 MB ArrayBuffer, copying takes time on both threads. Transferable objects move ownership instead: the buffer is handed to the worker almost instantly, and the sender can no longer use it.

const pixels = new Uint8ClampedArray(width * height * 4);
// ... fill pixels from a canvas

worker.postMessage({ id: 1, pixels }, [pixels.buffer]);

console.log(pixels.byteLength); // 0, the buffer now belongs to the worker

The second argument lists the objects to transfer. Inside the worker, send the result back the same way:

self.postMessage({ id, pixels }, [pixels.buffer]);

Transferables include ArrayBuffer, MessagePort, ImageBitmap, OffscreenCanvas, and streams. With Comlink, wrap the value with transfer(value, [buffer]) to get the same effect.

Handling Stale Results

If a user types quickly into a search box backed by a worker, results for "ap" might arrive after results for "apple". Ignore responses that are no longer relevant:

import { useEffect, useState } from "react";
import { useSearchWorker } from "./useSearchWorker";

type Item = { id: number; name: string };

export function WorkerSearch({ items }: { items: Item[] }) {
  const api = useSearchWorker();
  const [query, setQuery] = useState("");
  const [results, setResults] = useState<Item[]>([]);

  useEffect(() => {
    api.current?.load(items);
  }, [api, items]);

  useEffect(() => {
    let ignore = false;
    api.current?.search(query).then((found) => {
      if (!ignore) setResults(found);
    });
    return () => {
      ignore = true;
    };
  }, [api, query]);

  return (
    <>
      <input value={query} onChange={(e) => setQuery(e.target.value)} />
      <ul>
        {results.map((r) => (
          <li key={r.id}>{r.name}</li>
        ))}
      </ul>
    </>
  );
}

The ignore flag is the same pattern you use for fetch requests in effects. If a single task can run for seconds and must be stopped, the reliable option is worker.terminate() followed by creating a fresh worker, since there's no way to interrupt a synchronous loop from outside. Pair the worker with debounced input to avoid queuing a search for every keystroke.

When a Worker Is the Right Tool

Workers add complexity, so use them for work that's actually CPU-bound and long:

  • Parsing or transforming large files (CSV, JSON, XML)
  • Image and video processing
  • Search indexing and fuzzy matching over large datasets
  • Cryptography, compression, and hashing
  • Complex calculations for charts, simulations, or games

They're the wrong tool for:

  • Network requests. fetch is already asynchronous and doesn't block the main thread.
  • Slow rendering. If the cost is React rendering many components, use virtualization, memoization, or transitions. Workers can't render components.
  • Tiny tasks. Posting a message has overhead. If the work takes under a few milliseconds, the round trip costs more than it saves.

Measure with the Performance panel first. If you see long yellow scripting blocks from your own functions, a worker is a good candidate.

Common Mistakes with Web Workers in React

  • Creating a worker inside the render body. A new thread starts on every render. Create workers in an effect, or at module level for a shared singleton.
  • Forgetting to terminate. Workers keep running after the component unmounts unless you call terminate() in the effect cleanup.
  • Sending huge data on every call. Keep large datasets inside the worker and send only queries, or use transferables.
  • Trying to send functions or class instances. Structured cloning can't copy functions, and class instances arrive as plain objects without methods.
  • Using a plain string path. new Worker("./worker.ts") won't be bundled. Use new URL("./worker.ts", import.meta.url) so Vite processes it.
  • Ignoring errors. An exception inside a worker doesn't show up in your component. Catch errors in the worker and post them back, and listen for onerror on the main thread.

Frequently Asked Questions (FAQ) About Web Workers in React

No. Workers have no access to React, the DOM, or the main thread's variables. The worker posts a message with its result, and the component's message handler or promise callback calls the state setter on the main thread.

Yes, in Client Components. Next.js supports the same new Worker(new URL(...), import.meta.url) pattern. Create the worker inside an effect so it only runs in the browser, since workers don't exist during server rendering.

Usually one per kind of task, reused for the lifetime of the page. For parallel processing of large batches, a pool sized to navigator.hardwareConcurrency minus one is a common limit. Each worker uses memory and startup time, so avoid creating them per request.

A Web Worker runs computation for a single page and lives as long as that page uses it. A Service Worker sits between your site and the network, intercepts requests, enables offline caching and push notifications, and can run even when no page is open. They solve different problems.

Yes. With type: module workers in Vite, you can import packages as usual and they're bundled into the worker chunk. Packages that touch window or document will fail, so stick to libraries that work in non-DOM environments.

Keep the heavy logic in a plain function in its own module and unit test it directly in Vitest. The worker file then becomes a thin wrapper that receives messages and calls that function, which you can cover with an end-to-end test in a real browser.

Conclusion

Web Workers let you run CPU-heavy JavaScript on a background thread so React and the browser can keep responding to the user. In a Vite project you create them with new Worker(new URL(...), import.meta.url), talk to them with postMessage, and terminate them in an effect cleanup. A small promise-based hook or Comlink hides the message plumbing, transferables avoid copying large buffers, and an ignore flag keeps stale results out of your UI.

Find the longest task in your app with the Performance panel, move that function into a worker file, and wrap it with the useWorker hook from this post. Keep the computation in a plain module so it stays easy to test, and keep long-lived data inside the worker so each call only sends what changed.

Tags :
Share :

Related Posts

A Practical Guide to useEffect and Its Dependency Array

A Practical Guide to useEffect and Its Dependency Array

useEffect is the hook people get wrong most often, and the dependency array is usually where it goes wrong. Leave a value out and your effect works

Continue Reading
Accessibility Best Practices for React Developers

Accessibility Best Practices for React Developers

React makes it easy to build interfaces out of anything. A div with an onClick looks and behaves like a button for a mouse user, so it ships. The

Continue Reading
Animations in React with Motion (Framer Motion)

Animations in React with Motion (Framer Motion)

CSS transitions get you far, until you need to animate something leaving the page. React removes the element from the DOM immediately, so there's not

Continue Reading