Web Workers skill for Codex
Heavy work off the main thread, UI stays responsive. Codex checks that a worker is the right fix, isolates the long task as a pure function, and wires a module worker the way Vite, webpack and Next.js can bundle it. It uses Comlink for typed RPC, transfers large buffers instead of cloning them, keeps big datasets resident in the worker, adds cancellation for stale requests, and proves the main-thread long task is gone.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- web-workers Installs to ~/.agents/skills/web-workers/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install web-workers The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project web-workers Installs to .agents/skills/web-workers/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/web-workers.zipunzip -o web-workers.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) web-workers PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $web-workers, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Moves CPU-heavy browser work off the main thread with dedicated Web Workers, Comlink RPC, transferable objects, worker pools and OffscreenCanvas - parsing, search indexing, image and data processing, diffing, crypto, rendering - wired correctly for Vite, webpack and Next.js, with cancellation, error handling and tests. Use when long tasks freeze the UI, INP is poor because a handler computes too much, a large file or dataset is processed in the browser, or a canvas or chart renders slowly on the main thread.
Use it when
- A pure computation causes long tasks or poor INP
- Large files or datasets are processed in the browser
- Canvas or WebGL rendering competes with UI responsiveness
Use something else when
- You need offline caching, background sync or push: use pwa-service-workers
- The cost is React re-rendering rather than computation: use react-render-profiling
How to check the result
With 4x CPU throttling the long task leaves the main thread and shows on the worker track, INP for the interaction drops, and end-to-end time including transfer is no worse.
The mistake it prevents
Posting the same large dataset with every call, so structured cloning costs more main-thread time than the work you moved.
Done well means: the main thread stays responsive (no long tasks from the moved work), the result arrives at least as fast as before end to end, data crossing the boundary is transferred rather than copied when it is large, and stale work can be cancelled.
Is a worker the right tool?
| Situation | Choose |
|---|---|
| Pure computation > ~50 ms on a mid-range phone (parse, sort, filter 100k rows, diff, compress, hash, image filters) | Worker |
| Many short tasks, each < 10 ms | Keep on main thread; yield between chunks (scheduler.yield()) |
Work that needs the DOM (layout, measuring, document) | Main thread; reduce or batch it instead |
| React re-rendering cost | Not a worker problem: $react-render-profiling |
| Canvas or WebGL drawing that janks UI | OffscreenCanvas in a worker |
| Network caching, offline, push | Service worker ($pwa-service-workers), not a dedicated worker |
| Third-party scripts hogging the thread | Partytown or deferral: $third-party-scripts |
Measure first: DevTools Performance panel with 4x CPU throttle, find the long task, and note its duration and how much data goes in and out.
Workflow
- [ ] 1. Identify the long task and isolate it as a pure function (input -> output, no DOM)
- [ ] 2. Measure input/output size; plan transferables for large buffers
- [ ] 3. Create the worker with the bundler's module-worker syntax
- [ ] 4. Add an RPC layer (Comlink) or a typed message protocol
- [ ] 5. Add cancellation (terminate or request IDs) and error propagation
- [ ] 6. Test the pure function directly; smoke test the worker in a real browser
- [ ] 7. Re-measure main-thread long tasks and end-to-end time
Creating a worker (bundler-friendly)
// Works in Vite, webpack 5 and Next.js client components: the bundler sees the URL pattern
const worker = new Worker(new URL('./search.worker.ts', import.meta.url), { type: 'module' });
Keep new URL(..., import.meta.url) inline inside the Worker constructor call; storing
the URL in a variable first can stop bundlers from detecting and bundling the worker.
In Next.js, create workers only in client code (inside useEffect or an event handler),
never during server rendering where Worker is undefined.
Comlink (default RPC)
// search.worker.ts
import { expose } from 'comlink';
import { buildIndex, query } from './search-core'; // pure, unit-tested module
let index: ReturnType<typeof buildIndex> | null = null;
const api = {
init(docs: Doc[]) { index = buildIndex(docs); return index.size; },
search(q: string, limit = 20) { return index ? query(index, q, limit) : []; },
};
export type SearchApi = typeof api;
expose(api);
// main thread
import { wrap, transfer, type Remote } from 'comlink';
import type { SearchApi } from './search.worker';
const worker = new Worker(new URL('./search.worker.ts', import.meta.url), { type: 'module' });
const search: Remote<SearchApi> = wrap<SearchApi>(worker);
await search.init(docs);
const hits = await search.search(inputValue);
Without Comlink, use a discriminated-union message type with a request id and resolve
promises from a Map<id, resolver>; the same shape as Comlink, just manual.
Moving data cheaply
postMessage uses structured clone: copying 50 MB costs real main-thread time.
| Data | Approach |
|---|---|
ArrayBuffer, typed arrays, ImageBitmap, OffscreenCanvas, MessagePort | Transfer: zero-copy, sender loses access |
| Large arrays of objects | Convert to columnar typed arrays, or send once at init and keep in the worker |
File / Blob from an input | Post the File itself (cheap handle) and read it inside the worker |
| Shared mutable state | SharedArrayBuffer + Atomics, requires cross-origin isolation (COOP same-origin + COEP require-corp or credentialless); changing these headers can break third-party embeds, so get approval |
const pixels = ctx.getImageData(0, 0, w, h);
const out = await filters.blur(transfer(pixels.data.buffer, [pixels.data.buffer]), w, h);
// manual equivalent: worker.postMessage({ buf }, [buf])
OffscreenCanvas
const offscreen = canvas.transferControlToOffscreen();
renderWorker.postMessage({ type: 'init', canvas: offscreen, dpr: devicePixelRatio }, [offscreen]);
The worker draws with offscreen.getContext('2d') or WebGL and its own
requestAnimationFrame. Forward resize and pointer events as messages. Supported in
current Chromium, Firefox and Safari 17+; keep a main-thread fallback if older Safari
matters.
Cancellation, errors, pools
- Typing-driven work: tag requests with an incrementing id and drop stale responses, or
worker.terminate()and recreate for long jobs the user abandoned. - Errors thrown in the worker reject the Comlink promise; for raw workers listen to
errorandmessageerrorevents. Always surface a user-visible failure state. - One worker per CPU-bound job type is usually enough. For parallel batches, a small pool
sized
Math.max(1, navigator.hardwareConcurrency - 1), capped around 4 on mobile. - Worker startup costs tens of ms plus module download: create it early (on idle) for interactions that need instant results.
Testing
- Unit-test the pure core module (
search-core.ts) in Vitest or Jest directly; no worker needed. - jsdom has no real
Worker. Test the integration with Playwright in a real browser, or mock the Comlink remote in component tests.
Gotchas
- Workers have no
window,documentorlocalStorage;fetch,IndexedDB,crypto.subtleandOffscreenCanvasare available. - Libraries that touch
windowat import time crash in a worker; check before moving them. - Sending the same large dataset with every call erases the gain; send once, keep state in the worker.
- A worker does not make work faster, only off-thread. If the total time matters, also optimise the algorithm.
- CSP: workers loaded from blob URLs need
worker-src blob:; module workers from the same origin needworker-src 'self'(orscript-srcfallback).
Verification
- Performance panel (4x CPU): the long task is gone from the main thread and appears on the worker track; INP for the interaction drops below 200 ms.
- End-to-end time for the operation is equal or better (include transfer time).
- Rapid repeated input does not queue stale work (cancellation works).
- Production build loads the worker chunk (check Network for the worker file).
Report template
## Worker offload: <feature>
Moved: client-side search indexing + querying (src/search/search-core.ts)
Main thread long task: 680 ms -> 0 (index build), 140 ms -> 4 ms per keystroke
INP (typing in search, 4x CPU): 390 ms -> 90 ms
Data: docs sent once at init (2.1 MB clone, 35 ms); queries return top 20 only
Cancellation: stale query ids dropped; worker created on idle after load
Tests: search-core unit tests; Playwright smoke test for search box What the zip contains
SKILL.md plus the interface file below. Nothing runs on install.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "Web Workers"
short_description: "Heavy work off the main thread, UI stays responsive"
default_prompt: "$web-workers Parsing this 40 MB CSV freezes the page, move it to a web worker" About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.