Skip to main content
BlogWeb Dev

Web Workers in React: Offload the Main Thread with Comlink

Heavy CPU work freezes React at 0fps. Here is how I move it to a Web Worker with Comlink, when a worker beats a server call, and how to hold 60fps.

Matthews Wong

Software Engineer

July 20269 min read
Web Workers in React: Offload the Main Thread with Comlink

Photo by Auledas via Wikimedia Commons (CC BY-SA 4.0)

Key Takeaway

Move any synchronous JavaScript that runs longer than one 16ms frame off the main thread into a Web Worker. Wrap the worker with Comlink so calls look like normal async functions instead of postMessage plumbing. Reach for a worker before a server round-trip when the data is already in the browser and the cost is CPU, not network.

Every React app has one thread that paints the UI, runs your event handlers, and executes render. When a single function on that thread runs for 200ms, the page freezes for 200ms: clicks queue up, animations stutter, and your carefully tuned Interaction to Next Paint score falls off a cliff. I hit this on a data-heavy dashboard where parsing and reshaping a large CSV in the browser froze the tab for nearly half a second on every upload.

The fix is not always a faster algorithm or a backend endpoint. Sometimes the work genuinely belongs in the browser, and the right move is to run it on a different thread. That is what Web Workers give you, and Comlink makes them almost invisible. Below is how I decide when to use one, how I wire it up in a Vite build, and the traps that quietly cost you frames.

The 16ms budget, and why the main thread is sacred

At 60fps the browser has roughly 16 milliseconds to produce each frame. Inside that window it must run pending JavaScript, recalculate style and layout, and paint. Anything your code does synchronously eats into that budget. Go over it and you drop a frame; run a 200ms loop and you drop a dozen in a row while the UI sits frozen. Because Interaction to Next Paint is now a Core Web Vital, and a good score means 200ms or less at the 75th percentile of real interactions, these long tasks are no longer just an annoyance, they are a ranking signal.

A Web Worker is a separate JavaScript thread with its own event loop. It cannot touch the DOM and it does not share memory with your components, but it can run for as long as it needs without blocking a single frame on the main thread. The main thread stays free to paint and respond while the heavy work happens elsewhere.

Do not put a worker in front of cheap work. If a task finishes in under a frame, the cost of serializing arguments, posting a message, and deserializing the result usually outweighs any gain. My rule of thumb: profile first, and only reach for a worker when a single synchronous call reliably exceeds 16ms.

Raw workers communicate through postMessage and an onmessage handler. You send a message, you match responses by hand, and you end up building a tiny protocol every time. Comlink, a roughly one-kilobyte library from Google Chrome Labs, replaces all of that with ES6 Proxies. You expose an object from the worker, wrap the worker on the main side, and then call its methods as if they were local async functions. The proxy turns each call into a message under the hood and hands you back a promise.

Here is a complete example in a Vite plus TypeScript project. The worker exposes a heavy function; the main thread calls it and never blocks. Note the worker instantiation pattern, which Vite requires intact to bundle the worker as a separate chunk.

// heavy.worker.ts — runs on its own thread
import * as Comlink from 'comlink';

const api = {
  // Simulated CPU-bound work: parse + aggregate a large dataset
  aggregate(rows: number[]): { sum: number; mean: number } {
    let sum = 0;
    for (let i = 0; i < rows.length; i++) sum += rows[i];
    return { sum, mean: sum / rows.length };
  },
};

export type HeavyApi = typeof api;
Comlink.expose(api);
// useHeavyWorker.ts — main-thread hook
import * as Comlink from 'comlink';
import { useEffect, useMemo } from 'react';
import type { HeavyApi } from './heavy.worker';

export function useHeavyWorker() {
  const worker = useMemo(
    () =>
      new Worker(new URL('./heavy.worker.ts', import.meta.url), {
        type: 'module',
      }),
    [],
  );

  const api = useMemo(() => Comlink.wrap<HeavyApi>(worker), [worker]);

  // Terminate on unmount so you do not leak threads
  useEffect(() => () => worker.terminate(), [worker]);

  return api;
}
// Component.tsx — the call site looks synchronous, but never blocks
const api = useHeavyWorker();

async function onUpload(rows: number[]) {
  // UI stays at 60fps while this runs on the worker thread
  const result = await api.aggregate(rows);
  setStats(result);
}

When a worker beats a server round-trip

The instinct when something is slow in the browser is to push it to the backend. That is right when the work needs a database, a secret, or shared state. It is wrong when the data already lives in the browser and the only cost is CPU. Sending a large array to a server, waiting on the network, and parsing the response can easily take longer than the computation itself, and it burns bandwidth and a server request on work the user's own machine can do for free.

A worker keeps the data local, avoids the round-trip latency entirely, works offline, and scales with the user's device instead of your server budget. The table below is the decision I actually make.

FactorWeb WorkerServer round-trip
Where the cost isPure CPU, data already in browserNeeds DB, secrets, or shared state
LatencyNo network hopRound-trip plus queueing
OfflineWorks fully offlineFails without connectivity
Scaling costUses the user's CPU, free to youConsumes server capacity
Trust boundaryClient-side, do not trust the resultAuthoritative, validated server-side

A worker runs on the client, so its output is never authoritative. Filtering, sorting, and previewing in a worker is fine, but anything that gates money, permissions, or persisted state must still be validated on the server. Treat the worker as an accelerator for the UI, not a replacement for backend trust.

The traps: structured clone, leaks, and bundling

The most common surprise is serialization cost. Arguments and return values cross the thread boundary via the structured clone algorithm, which deep-copies the data. For a large typed array, that copy can itself blow your frame budget, so you have handed off the computation but reintroduced a stall on the copy. The fix is transferable objects: hand ownership of an ArrayBuffer to the worker instead of copying it. Comlink exposes this through its transfer helper.

import * as Comlink from 'comlink';

// Transfer the underlying buffer instead of cloning it.
// After transfer, `bytes` is neutered on the main thread — do not reuse it.
const bytes = new Uint8Array(hugeArrayBuffer);
await api.process(Comlink.transfer(bytes, [bytes.buffer]));
  • Leaking threads: a worker created per render without cleanup piles up threads. Create it once (useMemo or a module singleton) and call terminate on unmount.
  • Bundling: with Vite, keep the exact new Worker with new URL and import.meta.url pattern and pass type module, or the worker will not be bundled as its own chunk.
  • No DOM in the worker: it cannot read the document or touch React state. Return plain data and let the main thread render it.
  • Cold start: spinning up a worker costs a few milliseconds. For frequent calls, keep one worker alive rather than creating one per task.

A pragmatic default

I do not reach for a worker on every project. Most React jank is fixed with memoization, virtualization, or simply doing less work per render. But when profiling shows a genuine CPU-bound task that must run in the browser, a Comlink-wrapped worker is the cleanest tool I know: the call site reads like ordinary async code, the heavy work leaves the main thread, and the UI holds 60fps through the whole operation. That combination is what keeps Interaction to Next Paint green under real load.

FAQ

Frequently Asked Questions

Short answers to what readers ask most about this topic.

When should I use a Web Worker instead of just optimizing my React code?

Only after profiling shows a single synchronous task that reliably exceeds one 16ms frame. If memoization, virtualization, or doing less work per render fixes the jank, do that first. A worker adds serialization and cold-start overhead, so it only pays off for genuinely CPU-bound work like parsing large datasets, image processing, or heavy math.

What does Comlink actually do for Web Workers?

Comlink is a roughly one-kilobyte library from Google Chrome Labs that replaces manual postMessage plumbing with ES6 Proxies. You expose an object from the worker and wrap it on the main thread, then call its methods as if they were local async functions. Each call becomes a message under the hood and returns a promise, so the worker feels like ordinary async code.

When is a Web Worker better than calling a server endpoint?

When the data already lives in the browser and the only cost is CPU, not a database or a secret. A worker avoids network latency, works offline, and runs on the user's device instead of your server budget. Push work to the server when it needs authoritative state, credentials, or must be validated for security.

Why is my Web Worker still slow even after moving work off the main thread?

Most likely the structured clone cost. Arguments and results are deep-copied across the thread boundary, and copying a large typed array can itself blow your frame budget. Use transferable objects — hand ownership of the ArrayBuffer to the worker with Comlink's transfer helper — so the data is moved instead of copied.

Can a Web Worker update React state or the DOM directly?

No. Workers run on a separate thread with no access to the DOM or your React state. The worker should return plain serializable data, and the main thread updates state and renders. Keep all UI work on the main thread and use the worker purely for computation.