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.
Comlink: workers without the plumbing
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.
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 threadconst api = Comlink.wrap<typeof import('./comlink.worker').ComlinkApi>(worker);const { duration } = await api.heavySort(size); // that's it—no message protocolEvery 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 yourselfself.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/secsetInterval(() => foldBatch(agg, receiveBatch()), 16); // eat the whole firehosesetInterval(() => { self.postMessage({ type: 'snapshot', snapshot: toSnapshot(agg, MAX_POINTS) });}, 200); // tell React rarelyIn 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
| Symptom | Lever |
|---|---|
| Long CPU task (sort / parse / aggregate) | Web Worker (Comlink for ergonomics) |
| Heavy canvas / WebGL rendering | OffscreenCanvas (where supported) |
| Too many DOM nodes | Virtualization (Defense #1) |
| Network / offline / caching | Service Worker (Workbox) |
| Streaming firehose → UI | Worker aggregates + throttles, hand React small snapshots |
| Small / inexpensive work | Do 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.