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.
- name
web-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
- The slowness is rendering or re-renders — use react-rerenders or core-web-vitals instead
- You need bounded concurrency for network I/O — use async-concurrency instead
- You are compiling Rust or C++ to .wasm — use webassembly-modules instead
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.
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 (
PerformanceObserverwithtype: "long-animation-frame", Chromium 123+) givesblockingDurationand per-script attribution; theweb-vitalsattribution 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
| Work | Use | Why |
|---|---|---|
| A loop over many small items where the result is DOM updates | Yield 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 filters | Dedicated Worker | Runs in parallel; the main thread stays responsive |
| One connection or cache shared by several tabs of the same origin | SharedWorker | One instance per origin; check support — Chrome for Android only recently added it |
| Real-time audio processing | AudioWorklet | Runs on the audio rendering thread with tight timing |
| Canvas or WebGL rendering that janks the page | OffscreenCanvas in a worker | Rendering moves with the computation |
| Numeric hot loops that are still too slow in a worker | WebAssembly inside the worker | Faster compute; keep it off the main thread too |
| Network caching, offline, push | Service 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:
| Kind | Behavior |
|---|---|
| Plain objects, arrays, Map, Set, Date, RegExp, Blob, File, typed arrays | Structured clone (copied) |
ArrayBuffer, MessagePort, ImageBitmap, OffscreenCanvas, streams, VideoFrame | Transferable: moved at near-zero cost when listed in the transfer list |
| Functions, DOM nodes, class methods/prototypes | Not cloneable: DataCloneError, or class instances arrive as plain objects |
SharedArrayBuffer | Shared 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.
5. Worker API with Comlink
// 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); callproxy[Comlink.releaseProxy]()when done or they leak. UseComlink.transfer(value, [buffer])to move buffers. - Without Comlink, use
{ id, type, payload }messages and aMap<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
SharedArrayBufferflag), orworker.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 insidenew 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" */beforenew URL. - TypeScript: add
/// <reference lib="webworker" />at the top of worker files (or a separate tsconfig with"lib": ["ES2022", "WebWorker"]) soselfis 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 toscript-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, orlocalStorage; they do havefetch, 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
errorandmessageerrorlisteners (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
- 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.
scripts/message_cost.mjswith the real payload is within budget, and the recording shows serialization well below the work saved.- INP for the interaction (web-vitals attribution or the Performance panel’s interactions track) dropped; in the field, compare p75 before and after release.
- Typing or scrolling while the worker computes stays smooth; superseded requests do not overwrite newer results.
- 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
#!/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.