A glowing data pipeline flows through worker, rendering, and data-processing stages into a smoothly rendered display, illustrating Comlink, OffscreenCanvas, and off-main-thread streaming.

Off the Main Thread, Part 2: Comlink, OffscreenCanvas & The Fat Pipe

Part 8 of 10 in React Lunch & Learn

Part of the Off The Main Thread series. Part 1 is the foundations and your first worker win; this is the architecture.

Last time, in Part 1, we took one big job—sorting—and shoved it off the main thread using plain old postMessage. The result: no more frozen UI. But that win came with a lot of manual wiring, and all we really did was move the number-crunching part. This time, we’re leveling up in three ways: making workers easier to use, getting the screen drawing itself off the main thread too, and building a streaming data pipeline instead of a one-off solution. The guiding principle is the same as before: keep the thread that paints your UI as empty-handed as possible.

Raw postMessage is a walkie-talkie: fine if you just need to send a single message, but as soon as your worker starts doing more than one thing, you end up inventing a secret code. Suddenly, you need a type field to say what kind of message it is, a big onmessage switch statement to sort them out, and some way to match each reply to the question that started it. Before you know it, you’re writing a mini-protocol by hand. It’s repetitive, and more than a little tedious.

Comlink, which is also by Surma (the same developer I mentioned in Part 1), acts like a translator between you and your worker. Instead of passing messages back and forth, you just call methods on an object, and Comlink handles the rest behind the scenes. On the worker side, you use expose to share your API, and on the main thread, you use wrap to talk to it like it’s just another local object.

comlink.worker.ts
import * as Comlink from 'comlink';
import { heavySort } from '../shared/heavyTask';
const api = {
heavySort(size: number) {
const start = performance.now();
heavySort(size);
return { duration: performance.now() - start };
},
};
Comlink.expose(api);
// main thread
const api = Comlink.wrap<typeof import('./comlink.worker').ComlinkApi>(worker);
const { duration } = await api.heavySort(size); // that's it—no message protocol

Every method returns a Promise because, underneath it all, Comlink still uses postMessage. What Comlink really does is handle the back-and-forth, matching up requests and responses so you do not have to. Instead of wiring up a message handler, you just write await api.heavySort(rows) and get on with your day.

But I don’t want to lead you down the primrose path, so how about a little transparency: Comlink is about making things easier to use, not about breaking the rules of the browser. You still have the same fence between threads, and you still pay the same price for turning data into messages and back again. If you try to send a huge chunk of data, for example, it will still be slow. The DOM is still out of reach. If you only need to send one message, you can skip Comlink entirely. But if you’re building a worker API of any size, I think Comlink is the difference between code you can read and code that makes you want to quit.

The short version: postMessage is the low-level tool, and Comlink is the boilerplate-free wrapper around it. Which you reach for depends on what you’re doing. If you know up front the worker will grow an API with several methods, start with Comlink; for a fire-and-forget worker that handles one kind of message—an I/O shuttle, say—plain postMessage is less machinery.

OffscreenCanvas: move the rendering, not just the math

So far, we have only moved the number crunching off the main thread. Drawing pixels, however, still happens on the main thread by default. If you’re animating a canvas using requestAnimationFrame, that animation loop is running right on the main thread. If something blocks the thread, your canvas animation just stops, the same way the loading spinner froze earlier.

OffscreenCanvas moves that loop. You can hand off control of a canvas to a worker, and now the worker does the drawing. The main thread does not have to be involved in painting those pixels anymore.

// main thread: hand the canvas to a worker (once per canvas)
const offscreen = canvasEl.transferControlToOffscreen();
const worker = new Worker(new URL('./offscreen.worker.ts', import.meta.url), { type: 'module' });
worker.postMessage({ type: 'init', canvas: offscreen }, [offscreen]);
// offscreen.worker.ts: rAF isn't available in every worker context, so drive the loop yourself
self.onmessage = (e) => {
const ctx = e.data.canvas.getContext('2d');
setInterval(() => drawScene(ctx), 16); // keeps painting even when main is blocked
};

Let’s say you’re at the demo table with two canvases side by side, both painting the same bouncing ball. One is running its animation loop right on the main thread, using requestAnimationFrame. The other hands off its drawing to a worker. At first, you won’t be able to tell them apart. But try freezing the main thread—maybe by dragging a giant modal over the page or running a heavy script. The main-thread canvas freezes in place, like someone hit the world’s most literal pause button. Meanwhile, the worker-powered canvas just keeps animating, blissfully unaware that the rest of the page is stuck. It’s like having a backup generator for your graphics.

The same move is what keeps a Cesium-style globe spinning smoothly. Under the hood, a WebGL globe is pretty much a fancy canvas, and if you shove that rendering into a worker, the globe keeps spinning even if the main thread is busy doing something else. But there are a couple of potholes to watch for. First, browser support arrived late and unevenly. Chrome has had OffscreenCanvas for years; Firefox and Safari only caught up in 2022 and 2023 (and Safari’s WebGL contexts came a version later still), so older devices may miss it. Feature-detect transferControlToOffscreen before you try anything clever. And library support is uneven too—Plotly, for example, can’t use it because its renderer expects to talk to the DOM directly. OffscreenCanvas only helps when the drawing code is yours. For Plotly, you’re still stuck with WebGL traces (scattergl) and prepping your data in a worker, but the final draw call stays on the main thread.

Service Workers: the network layer

Not all workers do the same job. A Web Worker is like a helper that handles heavy computation. A Service Worker is more like a factory employee standing over a conveyor belt between your app and the network, with the ability to intercept requests as they go by. Both run off the main thread, but Service Workers focus on network and caching, not crunching numbers.

What do you get out of this? Offline support, instant repeat loads, and control over cache strategies—cache-first, network-first, or stale-while-revalidate, for example. Service Workers are about making your app feel fast, even if the code isn’t any faster under the hood. The tradeoff: you have to deal with a three-phase lifecycle—install, activate, fetch—and the classic snag: the stale service worker that keeps serving last week’s build after you deploy. Don’t hand-roll that lifecycle. Workbox takes care of precaching, cache strategies, and update flows for you.

If you’re fighting jank, Service Workers aren’t really the tool for that. It’s just good to know where their job ends, so you can pick the right kind of worker for the problem.

The fat data pipe: the real architecture

Data-heavy apps share a common headache. Picture a WebSocket blasting thousands of updates per second straight at your frontend. The simple setup is to handle socket onmessage on the main thread, call setState, and trigger a React re-render—over and over, thousands of times a second. Each update fights with your animation frame for attention. It’s death by a thousand messages.

The fix is architectural: move the WebSocket into a worker and let the worker act as a bouncer, handling the flood of updates, batching and trimming them down, and only passing along a neat, ready-to-render package to the main thread when it’s needed.

// streaming.worker.ts — ingest the firehose, emit a compact snapshot ~5x/sec
setInterval(() => foldBatch(agg, receiveBatch()), 16); // eat the whole firehose
setInterval(() => {
self.postMessage({ type: 'snapshot', snapshot: toSnapshot(agg, MAX_POINTS) });
}, 200); // tell React rarely

In the demo, “smart” mode is gulping down about 7,500 updates per second. The frame rate? Rock steady. That’s because a background worker is crunching the numbers, boiling all that chaos down to a single, tidy snapshot, and passing it to React maybe five times a second. Now, flip the switch to “naive” mode. Here, each raw batch gets dumped straight onto the main thread, which tries to keep up by aggregating and re-rendering for each one. The FPS meter takes a nosedive. The data and the aggregation logic are identical. The one difference is where the work happens and how often React gets pinged about it.

If you’ve ever streamed data into a Cesium globe or a Plotly chart, this will sound familiar. The worker acts like a bouncer at a crowded club: it keeps the raw data outside, lets in only the VIPs—the trimmed-down, ready-to-render positions and trace arrays. The thread in charge of drawing the frame never has to deal with the crowd outside, just the handful of guests who matter.

Choosing what to offload

SymptomLever
Long CPU task (sort / parse / aggregate)Web Worker (Comlink for ergonomics)
Heavy canvas / WebGL renderingOffscreenCanvas (where supported)
Too many DOM nodesVirtualization (Defense #1)
Network / offline / cachingService Worker (Workbox)
Streaming firehose → UIWorker aggregates + throttles, hand React small snapshots
Small / inexpensive workDo nothing—message cost outweighs the work

Almost none of these tricks make the work itself any faster (virtualization is the exception—it skips work outright). The main idea is still the same as in Part 1: keep the thread that draws your UI open and unblocked. Comlink helps you move work out of the way without pain. OffscreenCanvas lets you do the same for graphics. Service Workers handle the network side. The streaming pattern is like putting all these pieces together so data can flow through a wide-open pipe.

So keep asking the question: does this job really need to run on the thread that draws your UI? If not, you now have four different ways to move it somewhere else, and you know how to choose the right tool for the job.