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.

  • Performance
  • $web-workers
  • 162 lines
  • 2 files
  • ~1,750 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- web-workers

Installs to ~/.agents/skills/web-workers/ for every project on this machine.

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

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.

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

--- name: web-workers description: Moves CPU-heavy browser work off the main thread with dedicated Web Workers, Comlink RPC, transferable objects, worker pools and OffscreenCanvas -… license: MIT ---

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?

SituationChoose
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 msKeep 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 costNot a worker problem: $react-render-profiling
Canvas or WebGL drawing that janks UIOffscreenCanvas in a worker
Network caching, offline, pushService worker ($pwa-service-workers), not a dedicated worker
Third-party scripts hogging the threadPartytown 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.

// 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.

DataApproach
ArrayBuffer, typed arrays, ImageBitmap, OffscreenCanvas, MessagePortTransfer: zero-copy, sender loses access
Large arrays of objectsConvert to columnar typed arrays, or send once at init and keep in the worker
File / Blob from an inputPost the File itself (cheap handle) and read it inside the worker
Shared mutable stateSharedArrayBuffer + 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 error and messageerror events. 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, document or localStorage; fetch, IndexedDB, crypto.subtle and OffscreenCanvas are available.
  • Libraries that touch window at 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 need worker-src 'self' (or script-src fallback).

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.yamlgenerated from this page

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.

esc
Just launched

Codex Skills Pro is live

79 premium skills for Codex, each with scripts, references and an evidence report that proves the result.

Bought one by one$882.21
All 79 skills$49
94% off

That's $0.62 a skill, paid once.

Get all 79 for $49Or pick single skills from $4.99 →

Secure checkout by Creem