Performance No. 309

Web Workers skill for Claude Code

Get heavy computation off the main thread, measured. Claude starts from a Performance recording, not a hunch. It confirms that a long task is pure computation, tries yielding first, then moves the work into a worker that owns its data. A bundled script measures structured-clone and transfer cost for your real payload, and the skill covers Comlink RPC, stale-result guards, OffscreenCanvas and the exact new Worker(new URL(...)) form that Vite and webpack detect.

  • nameweb-workers
  • size230 lines · ≈2,800 tokens
  • files2
  • licenseMIT
  • updated

What Claude sees before loading it

This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.

description: Moves CPU-heavy browser work off the main thread with evidence — finding long tasks over 50 ms and slow INP interactions in the Performance panel, choosing between yielding, dedicated Web Workers, SharedWorker, worklets, OffscreenCanvas and WebAssembly, keeping postMessage cheap with transferables and structured-clone budgets, Comlink-style RPC, worker pools and cancellation, and bundler setup with new Worker(new URL(...)) in Vite and webpack. Use when parsing, search indexing, image processing or data crunching freezes the UI, a long task blocks input, or when wiring workers, Comlink or OffscreenCanvas into a build.

Try asking

  • Parsing this 30 MB CSV freezes the page. Move it off the main thread
  • Search-as-you-type is janky with 60k items. Should this be a web worker?
  • Set up a TypeScript module worker with Comlink in our Vite app

Use it when

  • A long task over 50 ms from your own computation blocks input or scrolling
  • INP is poor and the interaction's processing time is pure JavaScript work
  • Wiring workers, Comlink, SharedWorker or OffscreenCanvas into a Vite or webpack build
  • Worker messages are slow or the UI floods with results

Skip it when

How to check the result

Re-record the interaction at 4x CPU slowdown: the work runs on a worker track, no main-thread task from it exceeds 50 ms, and the message cost script stays within budget for the real payload.

The common mistake it prevents

Shipping a large dataset to the worker on every call, so structured cloning on the main thread costs as much as the computation it replaced.

~/.claude/skills/web-workers/SKILL.md

--- name: web-workers description: Moves CPU-heavy browser work off the main thread with evidence — finding long tasks over 50 ms and slow INP interactions in the Performance… license: MIT ---

A worker is worth it when the main thread is blocked by computation that does not need the DOM. Done well means: the long task was found in a recording, the work moved (or was split) so no main-thread task from it exceeds 50 ms, the cost of sending data to the worker was measured and is smaller than the work saved, and INP or the frame timeline proves the improvement.

Ground rules

  • Profile first. If the long task is rendering, layout, or framework re-renders, a worker will not help — fix that work (see react-rerenders or core-web-vitals).
  • Try the cheaper fixes in order: do less work, cache it, split it with yielding, then move it to a worker.
  • Message passing is not free: structured clone runs synchronously on the sender and the receiver. Send small messages, transfer buffers, and keep large data inside the worker.
  • One worker that owns its data beats a worker you ship the whole dataset to per call.
  • Every worker call can fail or be superseded: handle errors, cancel stale requests, terminate runaway work.

Workflow

- [ ] 1. Record the slow interaction; identify the long task and its call stack
- [ ] 2. Classify the work: DOM/render (not a worker job) or pure computation
- [ ] 3. Choose: yield, Web Worker, SharedWorker, worklet, OffscreenCanvas, WebAssembly
- [ ] 4. Measure message cost with a real payload (scripts/message_cost.mjs)
- [ ] 5. Design the worker API: worker owns data, small requests, transfer buffers
- [ ] 6. Wire the bundler (new Worker(new URL(...), { type: "module" }))
- [ ] 7. Add cancellation, errors, lifecycle (create lazily, terminate when done)
- [ ] 8. Re-record: no long task from this work; INP or frame times improved

1. Find the long task

  • Chrome DevTools Performance panel, CPU 4× slowdown, record the interaction. Long tasks (over 50 ms) have a red corner on the Main track. Open Bottom-Up for the self time.
  • Field and lab attribution: Long Animation Frames (PerformanceObserver with type: "long-animation-frame", Chromium 123+) gives blockingDuration and per-script attribution; the web-vitals attribution build links slow INP interactions to them.
  • INP is good at ≤ 200 ms (p75). If the interaction’s processing time is dominated by your own computation, it is a worker or yielding candidate.
new PerformanceObserver((list) => {
  for (const e of list.getEntries()) {
    if (e.blockingDuration > 0) console.log(e.duration, e.blockingDuration, e.scripts.map((s) => s.sourceURL));
  }
}).observe({ type: "long-animation-frame", buffered: true });

3. Choose the mechanism

WorkUseWhy
A loop over many small items where the result is DOM updatesYield inside the loop (scheduler.yield(), fallback setTimeout(0))Work needs the DOM; yielding lets input run between chunks
Pure computation over 50 ms: parse CSV/JSON, search index, diff, sort/aggregate large data, markdown or syntax highlighting, hashing, image filtersDedicated WorkerRuns in parallel; the main thread stays responsive
One connection or cache shared by several tabs of the same originSharedWorkerOne instance per origin; check support — Chrome for Android only recently added it
Real-time audio processingAudioWorkletRuns on the audio rendering thread with tight timing
Canvas or WebGL rendering that janks the pageOffscreenCanvas in a workerRendering moves with the computation
Numeric hot loops that are still too slow in a workerWebAssembly inside the workerFaster compute; keep it off the main thread too
Network caching, offline, pushService worker (pwa-service-workers skill)Not a compute thread; the browser may stop it any time

scheduler.yield() ships in Chromium 129+ and Firefox 142+, not in Safari; feature-detect:

const yieldToMain = () =>
  globalThis.scheduler?.yield ? scheduler.yield() : new Promise((r) => setTimeout(r, 0));

4. Measure the message cost

Run the bundled script with a sample of the real message:

node scripts/message_cost.mjs --json sample-payload.json --budget-ms 8
node scripts/message_cost.mjs --objects 50000          # synthetic records
node scripts/message_cost.mjs --bytes 50000000         # ArrayBuffer: copy vs transfer

It reports structured-clone time, postMessage round trip with copy and with transfer, and exits 1 when the one-way copy exceeds the budget. Node’s serializer approximates Chromium; confirm in the browser recording (the “Serialize”/“Deserialize” work appears around postMessage).

What crosses the boundary:

KindBehavior
Plain objects, arrays, Map, Set, Date, RegExp, Blob, File, typed arraysStructured clone (copied)
ArrayBuffer, MessagePort, ImageBitmap, OffscreenCanvas, streams, VideoFrameTransferable: moved at near-zero cost when listed in the transfer list
Functions, DOM nodes, class methods/prototypesNot cloneable: DataCloneError, or class instances arrive as plain objects
SharedArrayBufferShared memory; requires cross-origin isolation (Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp or credentialless); check crossOriginIsolated
// transfer the buffer, not the typed array
worker.postMessage({ pixels }, [pixels.buffer]);   // pixels.byteLength is 0 afterwards

If the message is too big: send ids or deltas, load the data inside the worker (it has fetch and IndexedDB), use columnar typed arrays instead of arrays of objects, or keep results in the worker and request only the visible slice.

// search.worker.ts
import * as Comlink from "comlink";
import { buildIndex, query } from "./search-core";   // pure functions, unit-testable

let index: ReturnType<typeof buildIndex> | null = null;

const api = {
  async load(url: string) { const docs = await (await fetch(url)).json(); index = buildIndex(docs); return docs.length; },
  search(q: string, limit = 50) { return index ? query(index, q, limit) : []; },   // returns a small slice
};
export type SearchApi = typeof api;
Comlink.expose(api);
// search-client.ts
import * as Comlink from "comlink";
import type { SearchApi } from "./search.worker";

let api: Comlink.Remote<SearchApi> | null = null;
export function searchWorker() {
  api ??= Comlink.wrap<SearchApi>(new Worker(new URL("./search.worker.ts", import.meta.url), { type: "module" }));
  return api;
}

// stale-result guard: only the latest query wins
let latest = 0;
export async function search(q: string) {
  const id = ++latest;
  const hits = await searchWorker().search(q);
  return id === latest ? hits : null;
}
  • Every Comlink call is async. Callbacks must be wrapped in Comlink.proxy(fn); call proxy[Comlink.releaseProxy]() when done or they leak. Use Comlink.transfer(value, [buffer]) to move buffers.
  • Without Comlink, use { id, type, payload } messages and a Map<id, {resolve, reject}> on the client; same rules.
  • Cancellation: a stale-id guard drops results; for long CPU loops, check a cancel flag between chunks (send a “cancel” message, or poll a SharedArrayBuffer flag), or worker.terminate() and recreate.
  • Pools: only for many independent jobs (image batch). Size to Math.max(1, (navigator.hardwareConcurrency ?? 4) - 1), capped (4 is a sensible cap on mobile), and queue the rest.

6. Bundler setup

const worker = new Worker(new URL("./parse.worker.ts", import.meta.url), { type: "module" });
  • Vite and webpack 5 both detect this exact form. Write new URL(...) inline inside new Worker(...) — assigning the URL to a variable first defeats detection (Vite then treats it as a plain asset; webpack does not support it). Options must be static literals.
  • Vite alternatives: import ParseWorker from "./parse.worker?worker" (default export is a constructor), ?worker&inline (base64-inlined), ?worker&url, ?sharedworker.
  • webpack 5 needs no worker-loader; name the chunk with /* webpackChunkName: "parse-worker" */ before new URL.
  • TypeScript: add /// <reference lib="webworker" /> at the top of worker files (or a separate tsconfig with "lib": ["ES2022", "WebWorker"]) so self is typed correctly.
  • SSR frameworks: create workers only in client code (inside an effect or event handler), never at module scope of code that runs on the server.
  • CSP: allow the worker script origin with worker-src (falls back to script-src).

OffscreenCanvas

const offscreen = canvas.transferControlToOffscreen();
worker.postMessage({ canvas: offscreen }, [offscreen]);
// in the worker: const ctx = e.data.canvas.getContext("2d"); requestAnimationFrame(draw);

Baseline widely available. Input events still arrive on the main thread: forward pointer coordinates and resize sizes to the worker as small messages.

Gotchas

  • Worker startup (script download, parse, init) costs time; create the worker before it is needed (on idle or when the feature mounts), not on the first click.
  • Workers have no window, document, or localStorage; they do have fetch, IndexedDB, crypto.subtle, WebSockets, and timers.
  • Posting one message per item in a loop floods the main thread with message events. Batch results and apply them once per frame.
  • Transferring detaches the sender’s buffer; code that reads it afterwards gets length 0.
  • The worker holds its own copy of data: moving a 200 MB dataset doubles memory unless the main thread drops its copy.
  • Errors inside the worker do not reject your promise unless you wire error and messageerror listeners (Comlink rethrows errors thrown inside exposed methods).
  • Unit tests in jsdom have no Worker. Keep the logic in pure modules tested directly; keep the worker file a thin adapter; cover the wiring with an e2e test.

Verification

  1. Re-record the same interaction at 4× CPU: no main-thread task over 50 ms from the moved work; the work appears on a worker track.
  2. scripts/message_cost.mjs with the real payload is within budget, and the recording shows serialization well below the work saved.
  3. INP for the interaction (web-vitals attribution or the Performance panel’s interactions track) dropped; in the field, compare p75 before and after release.
  4. Typing or scrolling while the worker computes stays smooth; superseded requests do not overwrite newer results.
  5. Kill the worker (throw inside it): the UI shows an error state and recovers.

If the task did not shrink, the expensive part was not the code you moved: return to step 1 with the new recording.

Report template

**Interaction:** typing in product search (INP p75 420 ms, lab long task 310 ms)
**Cause:** client-side filtering of 60k products on every keystroke (search-core.ts)
**Change:** index built and queried in a module worker via Comlink; worker owns data,
returns top 50 ids; stale-query guard
**Message cost:** 0.4 ms per query (message_cost.mjs, real payload)
**Result:** no long tasks during typing; lab INP 90 ms; field p75 to be confirmed after 28 days

Bundled files

Loaded only when a step in the skill needs them. Review them here before installing.

scripts/message_cost.mjs 124 lines · 5.5 KB
#!/usr/bin/env node
/**
 * Estimate what it costs to send a payload to a worker: structured-clone time,
 * postMessage round trip (copy vs. transfer), and serialized size.
 *
 * Usage:
 *   node message_cost.mjs --json <payload.json> [--runs 30] [--budget-ms 8]
 *   node message_cost.mjs --objects <count>     [--runs 30] [--budget-ms 8]
 *   node message_cost.mjs --bytes <count>       [--runs 30] [--budget-ms 8]
 *
 * --json      a sample of the real message (JSON file)
 * --objects   synthetic array of N small records ({id, name, price, tags})
 * --bytes     an ArrayBuffer of N bytes (also measures transfer instead of copy)
 * --runs      iterations per measurement (default 30); the median is reported
 * --budget-ms exit 1 if the median one-way copy cost exceeds this (default 8)
 *
 * Runs on Node 20+ with the standard library only (worker_threads, structuredClone).
 * Node's V8 serializer approximates Chromium's cost; confirm in the browser
 * Performance panel for the final number. Exit codes: 0 within budget,
 * 1 over budget, 2 bad arguments or unreadable input.
 */
import { readFileSync } from "node:fs";
import { performance } from "node:perf_hooks";
import { Worker } from "node:worker_threads";

function parseArgs(argv) {
  const out = { runs: 30, budgetMs: 8 };
  for (let i = 0; i < argv.length; i++) {
    const k = argv[i];
    const v = argv[i + 1];
    if (k === "--json") { out.json = v; i++; }
    else if (k === "--objects") { out.objects = Number(v); i++; }
    else if (k === "--bytes") { out.bytes = Number(v); i++; }
    else if (k === "--runs") { out.runs = Number(v); i++; }
    else if (k === "--budget-ms") { out.budgetMs = Number(v); i++; }
    else if (k === "-h" || k === "--help") { out.help = true; }
    else { throw new Error(`unknown argument: ${k}`); }
  }
  const modes = [out.json, out.objects, out.bytes].filter((x) => x !== undefined).length;
  if (out.help || modes !== 1) throw new Error("choose exactly one of --json, --objects, --bytes");
  for (const n of ["objects", "bytes", "runs", "budgetMs"]) {
    if (out[n] !== undefined && !(Number.isFinite(out[n]) && out[n] > 0)) throw new Error(`--${n} must be a positive number`);
  }
  return out;
}

function makePayload(a) {
  if (a.json) return { kind: "json", make: (() => { const p = JSON.parse(readFileSync(a.json, "utf8")); return () => p; })() };
  if (a.objects) {
    const p = Array.from({ length: a.objects }, (_, i) => ({ id: i, name: `item-${i}`, price: i * 1.5, tags: ["a", "b"] }));
    return { kind: "objects", make: () => p };
  }
  return { kind: "bytes", make: () => new ArrayBuffer(a.bytes) };
}

const median = (xs) => { const s = [...xs].sort((x, y) => x - y); const m = s.length >> 1; return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2; };
const fmt = (ms) => `${ms.toFixed(3)} ms`;

function sizeOf(value) {
  if (value instanceof ArrayBuffer) return value.byteLength;
  return Buffer.byteLength(JSON.stringify(value));
}

async function roundTrips(worker, make, runs, transfer) {
  const times = [];
  for (let i = 0; i < runs; i++) {
    const payload = make();
    const t0 = performance.now();
    await new Promise((resolve) => {
      worker.once("message", resolve);
      if (transfer) worker.postMessage(payload, [payload]);
      else worker.postMessage(payload);
    });
    times.push(performance.now() - t0);
  }
  return median(times);
}

async function main() {
  let args;
  try { args = parseArgs(process.argv.slice(2)); }
  catch (e) { console.error(`error: ${e.message}\n`); console.error(readFileSync(new URL(import.meta.url)).toString().split("*/")[0]); process.exit(2); }

  let payload;
  try { payload = makePayload(args); }
  catch (e) { console.error(`error: cannot read payload: ${e.message}`); process.exit(2); }

  const sample = payload.make();
  const clones = [];
  for (let i = 0; i < args.runs; i++) {
    const v = payload.make();
    const t0 = performance.now();
    structuredClone(v);
    clones.push(performance.now() - t0);
  }
  const cloneMs = median(clones);

  // The worker echoes a tiny ack so the round trip measures the outbound message.
  const worker = new Worker("const { parentPort } = require('node:worker_threads'); parentPort.on('message', () => parentPort.postMessage(0));", { eval: true });
  await roundTrips(worker, () => 0, 5, false); // warm up
  const baseMs = await roundTrips(worker, () => 0, args.runs, false);
  const copyMs = await roundTrips(worker, payload.make, args.runs, false);
  let transferMs = null;
  if (payload.kind === "bytes") {
    transferMs = await roundTrips(worker, () => new ArrayBuffer(args.bytes), args.runs, true);
  }
  await worker.terminate();

  const oneWayCopy = Math.max(copyMs - baseMs, cloneMs);
  console.log(`payload            ${payload.kind}, ~${(sizeOf(sample) / 1024).toFixed(1)} KiB serialized`);
  console.log(`structuredClone    ${fmt(cloneMs)} (median of ${args.runs})`);
  console.log(`empty round trip   ${fmt(baseMs)}`);
  console.log(`copy round trip    ${fmt(copyMs)}`);
  if (transferMs !== null) console.log(`transfer round trip ${fmt(transferMs)}  (ArrayBuffer moved, not copied)`);
  console.log(`one-way copy cost  ~${fmt(oneWayCopy)} on the sending thread and again to deserialize`);

  if (oneWayCopy > args.budgetMs) {
    console.log(`\nOVER BUDGET: ${fmt(oneWayCopy)} > ${args.budgetMs} ms. Send less (ids, deltas), use transferables (ArrayBuffer, typed array .buffer), or keep the data in the worker.`);
    process.exit(1);
  }
  console.log(`\nwithin budget (${args.budgetMs} ms)`);
}

main().catch((e) => { console.error(`error: ${e.stack || e.message}`); process.exit(2); });

About this skill

Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.

esc