8.2 — Offloading to workers¶
Module 8 · Lesson 2 · 🔴 Advanced · ~35 min
What you'll learn¶
- When a Web Worker actually pays for itself (and when the transfer cost eats the win)
- Comlink for ergonomic worker communication
- Partytown for third‑party scripts: real gains, real fragility
- The alternatives that are usually better
First: do you need a worker?¶
Workers solve exactly one problem: long‑running JavaScript blocking the main thread. They cost you a separate bundle, serialization overhead, no DOM access, and a lot of complexity.
Before reaching for one, check the cheaper options:
| Problem | Cheaper fix than a worker |
|---|---|
| Rendering 48 product cards is slow | Server Components, virtualization ([3.2], [5.4]) |
| Filtering is slow | Filter on the server (5.3) |
| One long loop | scheduler.yield() chunking (5.5) |
| Third‑party scripts | Facade, defer, or move server‑side (2.3) |
| Large JSON parse | Stream it, or shape it smaller on the server (7.1) |
| Image processing | Do it on the server / at the CDN (2.1) |
The break‑even rule: a worker is worth it when the computation exceeds roughly 50 ms and the data transferred is small relative to the work. Structured‑clone serialization is not free — posting a 5 MB array to compute a sum will be slower than doing it on the main thread.
Genuine commerce use cases¶
| Use case | Why a worker fits |
|---|---|
| Client‑side product search over a large local index | 200–800 ms of work, small query/result payloads |
| Image manipulation (crop/preview for a custom product) | Heavy pixel work, transferable ImageBitmap |
| Large CSV/order‑history export | Long, purely computational |
| Price/promo simulation for a big B2B cart (500+ lines) | Genuinely heavy, small inputs |
| Barcode / QR scanning in a store‑mode app | Continuous CPU, transferable frames |
| Third‑party script isolation (Partytown) | Different mechanism, same goal |
A worker, end to end¶
// workers/product-search.worker.ts
import MiniSearch from 'minisearch';
let index: MiniSearch | null = null;
type Request =
| { type: 'init'; products: IndexedProduct[] }
| { type: 'search'; query: string; requestId: number };
self.onmessage = (e: MessageEvent<Request>) => {
const msg = e.data;
if (msg.type === 'init') {
index = new MiniSearch({
fields: ['name', 'brand', 'category', 'tags'],
storeFields: ['id', 'name', 'price', 'image', 'slug'],
searchOptions: { boost: { name: 3, brand: 2 }, fuzzy: 0.2, prefix: true },
});
index.addAll(msg.products);
self.postMessage({ type: 'ready', count: msg.products.length });
return;
}
if (msg.type === 'search' && index) {
const results = index.search(msg.query).slice(0, 20);
// Include requestId so the main thread can discard stale responses
self.postMessage({ type: 'results', results, requestId: msg.requestId });
}
};
// hooks/use-worker-search.ts
'use client';
import { useEffect, useRef, useState, useCallback } from 'react';
export function useWorkerSearch(products: IndexedProduct[]) {
const workerRef = useRef<Worker>();
const requestId = useRef(0);
const [results, setResults] = useState<SearchResult[]>([]);
const [ready, setReady] = useState(false);
useEffect(() => {
// `new URL(..., import.meta.url)` is what lets the bundler emit the worker chunk
const worker = new Worker(
new URL('../workers/product-search.worker.ts', import.meta.url),
{ type: 'module' },
);
workerRef.current = worker;
worker.onmessage = (e) => {
if (e.data.type === 'ready') setReady(true);
// Discard responses for superseded queries
if (e.data.type === 'results' && e.data.requestId === requestId.current) {
setResults(e.data.results);
}
};
worker.postMessage({ type: 'init', products });
return () => worker.terminate();
}, [products]);
const search = useCallback((query: string) => {
workerRef.current?.postMessage({ type: 'search', query, requestId: ++requestId.current });
}, []);
return { search, results, ready };
}
Three details that matter:
requestIdcorrelation. Without it, a slow response for an earlier query overwrites the results for the current one.worker.terminate()on unmount. Leaked workers hold memory and keep running.new URL(..., import.meta.url)— this exact form is what bundlers detect to emit the worker as a separate chunk. String paths won't work.
Comlink¶
Message passing gets unwieldy fast. Comlink makes a worker look like an async module.
// workers/pricing.worker.ts
import * as Comlink from 'comlink';
const api = {
calculateBulkPricing(lines: CartLine[], rules: PricingRule[]): PricedLine[] {
// Genuinely expensive for a 500-line B2B cart: ~380ms
return lines.map((line) => applyPricingRules(line, rules));
},
simulatePromotions(cart: Cart, promos: Promotion[]): PromoSimulation[] {
return promos.map((p) => simulate(cart, p));
},
};
export type PricingApi = typeof api;
Comlink.expose(api);
'use client';
import * as Comlink from 'comlink';
import { useEffect, useRef } from 'react';
import type { PricingApi } from '@/workers/pricing.worker';
export function useBulkPricing() {
const apiRef = useRef<Comlink.Remote<PricingApi>>();
useEffect(() => {
const worker = new Worker(
new URL('../workers/pricing.worker.ts', import.meta.url),
{ type: 'module' },
);
apiRef.current = Comlink.wrap<PricingApi>(worker);
return () => worker.terminate();
}, []);
// Reads like a normal async call; runs off the main thread
return {
calculate: (lines: CartLine[], rules: PricingRule[]) =>
apiRef.current!.calculateBulkPricing(lines, rules),
};
}
Transferables: avoid the copy¶
postMessage structured‑clones by default — a full copy, on the main thread. For large binary
data, transfer ownership instead (zero copy).
// ❌ Copies 12MB on the main thread — the copy itself is a long task
worker.postMessage({ imageData });
// ✅ Transfers ownership; no copy. The buffer becomes unusable on this side.
worker.postMessage({ imageData }, [imageData.data.buffer]);
// ImageBitmap is transferable and ideal for image work
const bitmap = await createImageBitmap(blob);
worker.postMessage({ bitmap }, [bitmap]);
Transferable types: ArrayBuffer, MessagePort, ImageBitmap, OffscreenCanvas, and typed
array buffers.
Partytown¶
Partytown runs third‑party scripts in a Web Worker, using a proxy to give them synchronous‑looking DOM access. It can remove hundreds of milliseconds of main‑thread time.
// next/script has built-in support
<Script src="https://analytics.example.com/a.js" strategy="worker" />
What works and what doesn't¶
| Script type | Works? |
|---|---|
| Analytics (page views, events) | ✅ Usually |
| Conversion pixels | ✅ Usually |
| Tag manager (simple containers) | ⚠️ Depends heavily on what's inside |
| A/B testing that mutates the DOM | ❌ Timing‑sensitive; expect flicker |
| Chat widgets | ❌ Heavy DOM and event needs |
| Session replay | ❌ Needs precise DOM/event observation |
| Anything reading layout synchronously | ❌ |
The honest assessment¶
Real benefits: genuine main‑thread relief for analytics‑style scripts, and the biggest wins are on the sites with the worst tag stacks.
Real costs: - Every DOM access from the worker is a proxied synchronous round trip (implemented with a service worker or atomics). It's slower for the script, and scripts that touch the DOM a lot can end up worse overall. - Debugging is genuinely hard. Errors surface in a different context with unhelpful stacks. - Vendor support is unofficial. When the vendor ships an update that breaks under Partytown, you're on your own, and the failure mode is "conversions stopped being tracked" — discovered days later by the marketing team. - Some setups require a service worker, which brings its own considerations (8.3).
Adoption plan if you try it:
1. Pick ONE non-critical script (a secondary analytics tag).
2. Enable on one page type, behind a flag, at 10% of traffic.
3. Verify data completeness against the vendor's dashboard for 2 weeks —
compare event counts between the Partytown cohort and control.
4. Measure the TBT/INP delta.
5. Only then consider expanding.
6. Keep a same-day rollback: a flag flip, not a deploy.
Prefer moving scripts server‑side over Partytown. Server‑side tagging (2.3) gets you zero client cost instead of reduced client cost, with better data completeness and no fragility. Partytown is the answer for scripts you genuinely cannot move — not the first thing to try.
OffscreenCanvas¶
For product customizers, monogramming previews, and 3D viewers, rendering can move entirely off the main thread.
'use client';
export function ProductCustomizer({ productId }: Props) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const workerRef = useRef<Worker>();
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas || !('transferControlToOffscreen' in canvas)) return;
const offscreen = canvas.transferControlToOffscreen();
const worker = new Worker(
new URL('../workers/customizer.worker.ts', import.meta.url),
{ type: 'module' },
);
worker.postMessage({ type: 'init', canvas: offscreen, productId }, [offscreen]);
workerRef.current = worker;
return () => worker.terminate();
}, [productId]);
const updateText = (text: string) => {
workerRef.current?.postMessage({ type: 'text', text });
};
return <canvas ref={canvasRef} width={800} height={800} className="w-full" />;
}
Once transferred, the main thread can't draw to that canvas — all rendering must go through the
worker. Provide a fallback path for browsers without transferControlToOffscreen.
Web Worker gotchas¶
| Gotcha | Detail |
|---|---|
| No DOM access | Workers can't touch document or window |
| Serialization cost | Structured clone of large objects can exceed the compute you saved |
| Startup cost | 10–50 ms to spawn; pool or reuse them |
| Bundle duplication | Shared code gets bundled into both main and worker chunks |
| Debugging | A separate context in DevTools; sourcemaps must be configured |
| Memory | Each worker has its own heap; several workers on mobile is a problem |
| SSR | Workers don't exist on the server — guard all creation in effects |
// Reuse one worker rather than spawning per operation
let sharedWorker: Worker | null = null;
export function getSharedWorker(): Worker {
sharedWorker ??= new Worker(
new URL('../workers/compute.worker.ts', import.meta.url),
{ type: 'module' },
);
return sharedWorker;
}
Measuring the win¶
// Compare main-thread blocking, not total duration.
// A worker version often takes LONGER in wall-clock time and still wins,
// because the main thread stayed free.
async function benchmark() {
// Main thread
const t0 = performance.now();
const a = computeOnMainThread(data);
const mainMs = performance.now() - t0;
// Worker
const t1 = performance.now();
const b = await computeInWorker(data);
const workerMs = performance.now() - t1;
console.table({
mainThreadBlocking: mainMs, // this is what INP cares about
workerWallClock: workerMs, // includes transfer overhead
workerMainThreadBlocking: '~2ms', // just the postMessage
});
}
The right metric is main‑thread blocking time, from a Performance trace. If the worker version has a shorter total duration but the same long task on the main thread, you moved nothing.
Aurora's usage¶
| Feature | Approach | Result |
|---|---|---|
| PLP filtering | Server‑side (URL state) | No worker needed |
| Product search on the site | Server‑side (search service) | No worker needed |
| Store‑locator distance sorting (2,400 stores) | Worker | −180 ms main thread |
| Monogram preview canvas | OffscreenCanvas worker | 60 fps preview, main thread free |
| B2B bulk order pricing (500+ lines) | Comlink worker | −380 ms main thread |
| Analytics tags | Server‑side tagging, not Partytown | −660 ms, more reliable |
Three of six candidate use cases were better solved without a worker. That's the normal ratio, and it's the main point of this lesson.
Common mistakes¶
| Mistake | Cost |
|---|---|
| Using a worker where the server would do | Complexity for no reason |
| Transferring large data without transferables | The copy is a long task |
| Spawning a worker per operation | 10–50 ms startup each time |
No requestId correlation |
Stale results overwrite fresh ones |
| Not terminating workers | Memory leaks, zombie computation |
| Partytown on business‑critical tags | Silent data loss |
| Measuring wall‑clock instead of main‑thread blocking | Wrong conclusion about whether it helped |
| Creating workers during SSR | Crashes |
Lab 8.2 — Worker evaluation¶
- Find your longest main‑thread tasks (Performance panel, 4× CPU). List anything over 50 ms that isn't React rendering.
- For each, check the alternatives table. Can it go to the server? Be chunked? Be avoided?
- For the survivors, estimate transfer size vs compute time. Under 50 ms of compute, or a large payload, means a worker won't help.
- Prototype one with Comlink. Measure main‑thread blocking before and after in a trace.
- If you're considering Partytown, first estimate what server‑side tagging would achieve. Server‑side is almost always the better investment.
- Verify workers are terminated on unmount (Chrome Task Manager shows live workers).
Checklist¶
- Server‑side and chunking alternatives evaluated before adopting a worker
- Compute exceeds ~50 ms and payloads are small
- Transferables used for binary data
- Workers reused, not spawned per call
-
requestIdcorrelation for async responses -
terminate()on unmount - Worker creation guarded against SSR
- Partytown (if used) piloted on one non‑critical tag with data verification
- Success measured as main‑thread blocking time