Offloading Heavy Computations: Multi-Threading in React with Web Workers & Comlink
Unblocking the single-threaded JavaScript main UI loop: zero-copy ArrayBuffer transfers, background Web Worker pipelines, and Comlink RPC interfaces.
By Uttam Thapa · · Performance
⚡ Executive Summary (TL;DR)
The browser gives you one thread for events, layout, paint and your JavaScript — and 16.6 ms per frame to use it. A convolution over a 4K image or a
20 MB JSON parse blows that budget by orders of magnitude and freezes everything.
Web Workers move that work to a second thread, and Comlink removes the message-passing ceremony so a worker call reads
like an ordinary async function.
Figure 1: Filters running on a background thread — the sliders stay live while the image is processed.
One Thread, 16.6 Milliseconds
Every click, drag, style recalculation, layout, paint and line of your JavaScript competes for the same main thread. To hold 60 fps, everything queued for a
frame must finish inside 16.6 ms. A synchronous loop that runs for 400 ms does not slow the interface down — it stops it: no hover states, no scrolling,
no cursor change, and often a browser "page unresponsive" prompt.
| Task duration |
What the user perceives |
Action |
| < 16ms | Nothing — fits in a frame | Leave it on the main thread |
| 16–50ms | A dropped frame or two | Acceptable if rare; do not do it per keystroke |
| 50–200ms | Input feels laggy; INP degrades | Yield, or move to a worker |
| > 200ms | Frozen | Worker, without question |
The 50 ms line is the one worth remembering: it is the threshold at which a task is classified as long, and the point past which
Interaction to Next Paint starts to suffer regardless of how fast the rest of the application is.
Comlink Removes the Ceremony
Raw workers communicate through postMessage, which means inventing a message protocol, correlating replies to requests, and switching on a
type field. Comlink wraps that in a proxy so the worker's exported object can be called directly.
// imageWorker.ts — the background thread
import * as comlink from 'comlink';
export const workerAPI = {
processSobelFilter(pixelData: Uint8ClampedArray, width: number, height: number) {
// Heavy spatial convolution over a 4K frame, off the main thread.
return new Uint8ClampedArray(pixelData.length);
},
};
comlink.expose(workerAPI);
// The main thread calls it like any async API.
import * as comlink from 'comlink';
const worker = new Worker(new URL('./imageWorker.ts', import.meta.url), { type: 'module' });
const workerProxy = comlink.wrap<typeof workerAPI>(worker);
const result = await workerProxy.processSobelFilter(imageData, 3840, 2160);
The new URL(..., import.meta.url) form matters — it is what lets Vite and other bundlers detect the worker at build time and emit it as its own
chunk. A plain string path silently produces a worker that 404s in production.
The Cost of Crossing the Boundary
Data passed to a worker is structured-cloned by default: copied, not shared. For a 4K RGBA frame that is roughly 33 MB copied in, and another 33 MB
copied back — enough to erase the benefit of moving the work at all.
// Transfer the underlying buffer instead of copying it. Ownership moves to
// the worker, so the main thread's view becomes unusable — which is exactly
// what you want when the worker is about to rewrite it.
const result = await workerProxy.processSobelFilter(
comlink.transfer(imageData, [imageData.buffer]),
3840,
2160,
);
🚨 What a worker cannot do
- No DOM. There is no
document and no window. Workers compute; the main thread renders.
- Functions do not clone. You cannot pass a callback as an argument without wrapping it in
comlink.proxy().
- Startup is not free. Spawning a worker costs a few milliseconds, so create it once and reuse it rather than per operation.
- Errors need handling. An uncaught throw inside a worker surfaces as an error event, not as a rejected promise, unless the wrapper handles it.
What Belongs on a Worker
🖼️
Image processing
Filters, resizing, format conversion — per-pixel loops are the canonical worker workload.
📄
Large parsing
Multi-megabyte JSON or CSV. JSON.parse is synchronous and blocks for its entire duration.
🔍
Search and crypto
Client-side indexing, embedding similarity, hashing and key derivation.
💡 Sometimes yielding is enough
A worker is not the only tool. If the work splits naturally into chunks, processing a slice per frame and yielding between them keeps the interface
responsive with none of the transfer cost or bundling complexity. Reach for a worker when the task is genuinely indivisible or long enough that chunking it
would still take seconds.
✅ Key takeaways
- ✓50 ms is the line. Past it a task is long, and INP degrades no matter what else you optimise.
- ✓Comlink turns
postMessage into function calls, and keeps the types across the boundary.
- ✓Transfer buffers, do not clone them. Copying 33 MB twice cancels out the work you moved.
- ✓Use
new URL(..., import.meta.url). A string path builds fine and breaks in production.
- ✓Reuse one worker. Spawning per operation reintroduces the cost you were avoiding.
- ✓Consider chunk-and-yield first. For divisible work it is simpler and just as smooth.
Related: building UT Studio covers the canvas pipeline these filters feed, and
eliminating layout jank at 60fps covers the rendering half of the same frame budget.
Frequently asked questions
When should work be moved to a Web Worker?
When a single synchronous task exceeds roughly 50 milliseconds. Past that the main thread cannot respond to input, and INP degrades regardless of how fast the rest of the application is.
What does Comlink do?
It wraps the worker's postMessage protocol in a proxy so you call worker functions as if they returned promises. The message plumbing and correlation of replies disappear.
What cannot be done in a Web Worker?
Anything touching the DOM. Workers have no document, so they are for computation — parsing, image processing, cryptography, search — with the results posted back for the main thread to render.
Home · Projects · Blog · Services · Résumé · Contact