Web Workers in React: Offload the Main Thread with Comlink

Photo by Auledas via Wikimedia Commons (CC BY-SA 4.0)
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.
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 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.
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.
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.

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.
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);
}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.
| Factor | Web Worker | Server round-trip |
|---|---|---|
| Where the cost is | Pure CPU, data already in browser | Needs DB, secrets, or shared state |
| Latency | No network hop | Round-trip plus queueing |
| Offline | Works fully offline | Fails without connectivity |
| Scaling cost | Uses the user's CPU, free to you | Consumes server capacity |
| Trust boundary | Client-side, do not trust the result | Authoritative, 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 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]));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.