Introduction
Long synchronous tasks on the main thread — parsing big CSVs, aggregating WebSocket firehoses, or heavy image work — make inputs stutter and renders lock up. Moving CPU-bound work off the main thread is one of the most effective ways to keep a React UI responsive. Comlink makes threading ergonomic by turning a worker into a proxy object with minimal ceremony.
Below are small, repeatable patterns I use in TypeScript + React hooks to make workers reliable, testable, and easy to reason about.
Why Comlink + Workers
- Comlink exposes a worker API as a Promise-backed proxy, so your call sites look like normal async functions.
- Keep lifecycle and allocation out of React renders by owning workers in module-level services (or a pool).
- Use transferables (ArrayBuffer, OffscreenCanvas, ImageBitmap, MessagePort) to avoid expensive cloning.
Core patterns (overview)
- useWorker hook that wraps a Comlink API and handles lifecycle + transferables.
- A singleton worker-service (or lightweight pool) so many components share the same workers.
- runId invalidation to drop stale results from fast or overlapping jobs.
- OffscreenCanvas for any heavy drawing you want off-main-thread.
1) Worker service: create once, use everywhere
Create the worker and Comlink proxy outside of components. An accessor function keeps initialization lazy and safe for SSR/HMR.
// worker-service.ts
import * as Comlink from 'comlink';
import type { Api } from './worker';
let api: Comlink.Remote<Api> | null = null;
export function workerApi() {
if (!api) {
const wr = new Worker(new URL('./worker.ts', import.meta.url), { type: 'module' });
api = Comlink.wrap<Api>(wr);
if ((import as any).meta?.hot) { /* optionally terminate on HMR */ }
}
return api;
}
This keeps worker construction out of rendering and lets many components call workerApi() inside effects without recreating the worker.
2) useWorker hook + runId invalidation
A tiny hook that issues a request in an effect, increments a runId to track the most recent run, and ignores stale results when they arrive.
import { useEffect, useRef, useState } from 'react';
import { workerApi } from './worker-service';
export function useAggregate(batch: ArrayBuffer | null) {
const [state, setState] = useState({ status: 'idle' as const, data: null as any });
const runId = useRef(0);
useEffect(() => {
if (!batch) return setState({ status: 'idle', data: null });
const id = ++runId.current;
setState({ status: 'pending', data: null });
(async () => {
try {
const api = workerApi();
// transfer the buffer for zero-copy
const result = await api.aggregate(Comlink.transfer(batch, [batch]));
if (id === runId.current) setState({ status: 'done', data: result });
} catch (e) {
if (id === runId.current) setState({ status: 'error', data: e });
}
})();
return () => { runId.current++; }; // invalidate on cleanup
}, [batch]);
return state;
}
Why runId? Promises can’t be cancelled reliably. For many UI cases it’s fine that the work continues in the worker; you only need to ignore out-of-order results.
If a job is truly long-running and wasteful to continue, add a cancellation API to the worker and call it from the cleanup (or use SharedArrayBuffer + Atomics for cancellation checks inside chunked work).
3) Transferables and zero-copy
postMessage does structured-clone by default. For large binary payloads that copy can cost more than the computation. Transfer the underlying buffer instead:
// On the main thread
api.process(Comlink.transfer(bigArrayBuffer, [bigArrayBuffer]));
// bigArrayBuffer is detached on the caller side after transfer
Supported transferables include ArrayBuffer, ImageBitmap, OffscreenCanvas, and MessagePort. If you forget this, the app will still work but you’ll see surprising slowness.
4) Worker pools for parallel CPU-bound jobs
For heavy workloads or many independent tasks, a small pool that round-robins or schedules tasks keeps cores busy while keeping worker count sane.
High-level libraries (or a short WorkerPool class) can wrap Comlink proxies and expose a typed API. Key knobs: pool size (usually navigator.hardwareConcurrency - 1), queue limits, per-task timeouts, and graceful termination.
5) OffscreenCanvas for render work
If your bottleneck is drawing (animating thousands of points, complex WebGL, or frame-by-frame image ops), transfer a canvas to a worker:
// main thread
const off = canvas.transferControlToOffscreen();
worker.postMessage({ canvas: off }, [off]);
// worker: get a 2D or webgl context and draw freely
const ctx = offscreen.getContext('2d');
This removes paint work from the main thread and can eliminate jank that remains even after offloading computation.
Testing and ergonomics
- Test hooks by mocking the worker-service accessor — the hook contract is simple: issue a request, ignore stale replies, and expose states (idle/pending/done/error).
- Keep the worker module small and well-typed. Comlink preserves types across the proxy boundary (with TypeScript), making integration straightforward.
- Don’t construct workers in component bodies or rely on useMemo for worker lifetimes; React may discard memoized values and Strict Mode complicates construction. The module-owned service is safer.
Pitfalls and when not to use workers
- Workers don’t help long network latency. They solve synchronous main-thread work.
- Transferring a buffer detaches it on the sender; subsequent reads will fail. Make ownership explicit in code.
- SharedArrayBuffer needs cross-origin isolation headers — only reach for shared memory when messaging is the bottleneck and you can change deployment headers.
Example: incremental WebSocket aggregation (summary)
For a WS firehose, increment a runId per batch, transfer batch ArrayBuffers to the worker, and discard out-of-order responses on return. This pattern keeps React responsive while the worker aggregates.
const api = Comlink.wrap(new Worker(new URL('./agg.worker', import.meta.url)));
runId.current++;
api.aggregate(Comlink.transfer(batch, [batch]), runId.current).then((res) => {
if (runId.current !== res.runId) return; // drop stale
// apply result
});
Conclusion
Move the CPU-bound pieces that block paints into workers, but do it with a few guardrails: own workers in module services (or pools), use transferables for big binary payloads, ignore stale replies with a runId, and use OffscreenCanvas for heavy drawing. With Comlink you keep the ergonomics and types you already rely on, and React stays snappy.
What’s one CPU-bound task in your app you’d try moving to a worker today?
Top comments (0)