Skip to content

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:

  1. requestId correlation. Without it, a slow response for an earlier query overwrites the results for the current one.
  2. worker.terminate() on unmount. Leaked workers hold memory and keep running.
  3. 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.

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" />
// next.config.ts
const config: NextConfig = {
  experimental: { nextScriptWorkers: true },
};

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

  1. Find your longest main‑thread tasks (Performance panel, 4× CPU). List anything over 50 ms that isn't React rendering.
  2. For each, check the alternatives table. Can it go to the server? Be chunked? Be avoided?
  3. For the survivors, estimate transfer size vs compute time. Under 50 ms of compute, or a large payload, means a worker won't help.
  4. Prototype one with Comlink. Measure main‑thread blocking before and after in a trace.
  5. If you're considering Partytown, first estimate what server‑side tagging would achieve. Server‑side is almost always the better investment.
  6. 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
  • requestId correlation 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

Next: 8.3 Service workers & bfcache