Web Workers
Master Web Workers for off-main-thread computation — dedicated workers for CPU-intensive tasks, Comlink for ergonomic worker communication, SharedArrayBuffer for zero-copy data sharing, worker pooling for throughput, and integration patterns with React and bundlers.
When to Use
- JSON parsing of large payloads (>1MB) blocks the main thread and causes jank
- Complex calculations (data transformation, sorting, filtering) exceed the 50ms Long Task threshold
- Image processing (resizing, filters, format conversion) needs to happen client-side
- Syntax highlighting, Markdown rendering, or code compilation blocks user input
- A real-time application needs continuous computation without affecting UI responsiveness
- Search indexing or full-text search over large client-side datasets causes frame drops
- Encryption or hashing operations block the main thread
- A spreadsheet or data grid application performs heavy cell computations
- WebAssembly modules need a dedicated thread for parallel execution
- INP is poor because computation and rendering compete for the main thread
Instructions
Create a dedicated worker for CPU-intensive tasks. Move computation off the main thread:
// worker.ts — runs in a separate thread
self.addEventListener('message', (event) => {
const { type, data } = event.data;
switch (type) {
case 'sort': {
const sorted = data.sort((a, b) => a.score - b.score);
self.postMessage({ type: 'sorted', data: sorted });
break;
}
case 'filter': {
const filtered = data.filter((item) => item.name.toLowerCase().includes(event.data.query));
self.postMessage({ type: 'filtered', data: filtered });
break;
}
}
});
// main.ts — UI thread
const worker = new Worker(new URL('./worker.ts', import.meta.url), {
type: 'module',
});
worker.addEventListener('message', (event) => {
const { type, data } = event.data;
if (type === 'sorted') {
renderSortedList(data);
}
});
// Send work to the worker (non-blocking)
worker.postMessage({ type: 'sort', data: largeDataset });
Use Comlink for ergonomic worker communication. Comlink wraps postMessage with an RPC-style API:
// worker.ts — expose functions via Comlink
import * as Comlink from 'comlink';
const api = {
async processData(items: Item[]): Promise<ProcessedItem[]> {
// Heavy computation runs off main thread
return items.map((item) => ({
...item,
score: calculateComplexScore(item),
rank: determineRank(item),
}));
},
async search(items: Item[], query: string): Promise<Item[]> {
// Full-text search with ranking
return items
.filter((item) => fuzzyMatch(item.name, query))
.sort((a, b) => relevanceScore(b, query) - relevanceScore(a, query));
},
};
Comlink.expose(api);
// main.ts — call worker functions like normal async functions
import * as Comlink from 'comlink';
const worker = new Worker(new URL('./worker.ts', import.meta.url), {
type: 'module',
});
const api = Comlink.wrap<typeof import('./worker').api>(worker);
// Looks like a regular async function call
const processed = await api.processData(largeDataset);
const results = await api.search(items, 'query');
Use Transferable objects for zero-copy data transfer. Large ArrayBuffers can be transferred to workers without copying:
// Transfer (zero-copy) — ownership moves to worker, original is detached
const buffer = new ArrayBuffer(1024 * 1024); // 1MB
worker.postMessage({ buffer }, [buffer]);
// buffer.byteLength is now 0 — ownership transferred
// Structured clone (copy) — default behavior, copies data
worker.postMessage({ data: largeArray });
// Both threads have their own copy — 2x memory usage
// Transfer ImageBitmap for image processing
const bitmap = await createImageBitmap(imageBlob);
worker.postMessage({ bitmap }, [bitmap]);
Implement a worker pool for parallel throughput. Create navigator.hardwareConcurrency workers at startup. Maintain a busy set and task queue. When a task arrives, dispatch to an idle worker or enqueue. On completion, resolve the promise and process the next queued task:
class WorkerPool {
private workers: Worker[] = [];
private queue: Array<{ task: any; resolve: Function; reject: Function }> = [];
private busy = new Set<Worker>();
constructor(workerUrl: URL, poolSize = navigator.hardwareConcurrency || 4) {
for (let i = 0; i < poolSize; i++) {
const w = new Worker(workerUrl, { type: 'module' });
w.addEventListener('message', (e) => this.onComplete(w, e.data));
this.workers.push(w);
}
}
exec(task: any): Promise<any> {
return new Promise((resolve, reject) => {
const idle = this.workers.find((w) => !this.busy.has(w));
if (idle) {
this.dispatch(idle, task, resolve, reject);
} else {
this.queue.push({ task, resolve, reject });
}
});
}
private dispatch(w: Worker, task: any, resolve: Function, reject: Function) {
this.busy.add(w);
(w as any).__resolve = resolve;
w.postMessage(task);
}
private onComplete(w: Worker, result: any) {
(w as any).__resolve(result);
this.busy.delete(w);
const next = this.queue.shift();
if (next) this.dispatch(w, next.task, next.resolve, next.reject);
}
terminate() {
this.workers.forEach((w) => w.terminate());
}
}
Use SharedArrayBuffer for real-time shared state. Requires COOP (same-origin) and COEP (require-corp) headers. Create a SharedArrayBuffer, wrap in Int32Array, and send to multiple workers. Use Atomics.add/load/store for thread-safe reads and writes, and Atomics.wait/notify for synchronization. This avoids all serialization overhead for numeric data.
Integrate workers with React. Create a useWorker hook: instantiate the worker in useEffect, return { result, loading, execute }, and terminate on cleanup. This manages lifecycle and prevents leaks:
function useWorker<T>(workerFactory: () => Worker) {
const workerRef = useRef<Worker | null>(null);
const [result, setResult] = useState<T | null>(null);
const [loading, setLoading] = useState(false);
useEffect(() => {
workerRef.current = workerFactory();
workerRef.current.addEventListener('message', (e) => {
setResult(e.data);
setLoading(false);
});
return () => workerRef.current?.terminate();
}, []);
const execute = useCallback((data: any) => {
setLoading(true);
workerRef.current?.postMessage(data);
}, []);
return { result, loading, execute };
}
Configure bundlers for worker support. Vite supports import MyWorker from './worker?worker' or the standard new URL('./worker.ts', import.meta.url) pattern. Webpack 5 and esbuild also support the new URL() pattern natively (worker-loader is no longer needed).
Details
Worker Thread Cost
Worker creation takes ~40-100ms; each consumes ~1-5MB for its V8 isolate. Structured clone serialization runs at ~400MB/s for typed arrays, ~50MB/s for complex objects. A 10MB JSON dataset takes ~200ms to serialize, potentially negating the benefit. Use Transferable objects or SharedArrayBuffer to avoid copy cost.
Worked Example: Figma Canvas Rendering
A dedicated worker parses the binary file format, computes layout constraints, and generates render commands sent to the main thread via Transferable ArrayBuffers for WebGL submission. Result: opening 10,000+ layer files does not block UI; INP stays under 50ms.
Worked Example: Google Sheets Calculations
Cell recalculation (dependency graph traversal + formula evaluation) runs in a worker pool. SharedArrayBuffer stores the cell value grid so all workers read current state without serialization. Result: responsive typing and scrolling even during heavy recalculation.
Anti-Patterns
Moving trivial computation to workers. If the computation takes <5ms, the overhead of postMessage serialization (~1ms) and worker context switching exceeds the benefit. Only offload computation that takes >50ms on the main thread.
Creating a new worker per task. Worker creation takes ~50ms. Reuse workers by sending new tasks via postMessage. Create workers at application startup, not on demand.
Sending large objects via postMessage without Transferable. Sending a 50MB ArrayBuffer via structured clone takes ~125ms and doubles memory usage. Use Transferable objects (postMessage(data, [buffer])) for zero-copy transfer.
Ignoring worker errors. Uncaught errors in workers are silently swallowed by default. Always add onerror and onmessageerror handlers to workers for debugging and resilience.
Source
Process
- Read the instructions and examples in this document.
- Apply the patterns to your implementation, adapting to your specific context.
- Verify your implementation against the details and edge cases listed above.
Harness Integration
- Type: knowledge — this skill is a reference document, not a procedural workflow.
- No tools or state — consumed as context by other skills and agents.
Success Criteria
- CPU-intensive tasks (>50ms) run in Web Workers, not on the main thread.
- Worker communication uses Comlink or a similar RPC abstraction for ergonomics.
- Large data transfers use Transferable objects to avoid serialization overhead.
- Workers are reused (not created per task) and properly terminated on cleanup.
- Main thread INP remains under 200ms even during heavy background computation.
1---2name: perf-web-workers3description: Web Workers4---5# Web Workers67> Master Web Workers for off-main-thread computation — dedicated workers for CPU-intensive tasks, Comlink for ergonomic worker communication, SharedArrayBuffer for zero-copy data sharing, worker pooling for throughput, and integration patterns with React and bundlers.89## When to Use1011- JSON parsing of large payloads (>1MB) blocks the main thread and causes jank12- Complex calculations (data transformation, sorting, filtering) exceed the 50ms Long Task threshold13- Image processing (resizing, filters, format conversion) needs to happen client-side14- Syntax highlighting, Markdown rendering, or code compilation blocks user input15- A real-time application needs continuous computation without affecting UI responsiveness16- Search indexing or full-text search over large client-side datasets causes frame drops17- Encryption or hashing operations block the main thread18- A spreadsheet or data grid application performs heavy cell computations19- WebAssembly modules need a dedicated thread for parallel execution20- INP is poor because computation and rendering compete for the main thread2122## Instructions23241. **Create a dedicated worker for CPU-intensive tasks.** Move computation off the main thread:2526 ```typescript27 // worker.ts — runs in a separate thread28 self.addEventListener('message', (event) => {29 const { type, data } = event.data;3031 switch (type) {32 case 'sort': {33 const sorted = data.sort((a, b) => a.score - b.score);34 self.postMessage({ type: 'sorted', data: sorted });35 break;36 }37 case 'filter': {38 const filtered = data.filter((item) => item.name.toLowerCase().includes(event.data.query));39 self.postMessage({ type: 'filtered', data: filtered });40 break;41 }42 }43 });4445 // main.ts — UI thread46 const worker = new Worker(new URL('./worker.ts', import.meta.url), {47 type: 'module',48 });4950 worker.addEventListener('message', (event) => {51 const { type, data } = event.data;52 if (type === 'sorted') {53 renderSortedList(data);54 }55 });5657 // Send work to the worker (non-blocking)58 worker.postMessage({ type: 'sort', data: largeDataset });59 ```60612. **Use Comlink for ergonomic worker communication.** Comlink wraps postMessage with an RPC-style API:6263 ```typescript64 // worker.ts — expose functions via Comlink65 import * as Comlink from 'comlink';6667 const api = {68 async processData(items: Item[]): Promise<ProcessedItem[]> {69 // Heavy computation runs off main thread70 return items.map((item) => ({71 ...item,72 score: calculateComplexScore(item),73 rank: determineRank(item),74 }));75 },7677 async search(items: Item[], query: string): Promise<Item[]> {78 // Full-text search with ranking79 return items80 .filter((item) => fuzzyMatch(item.name, query))81 .sort((a, b) => relevanceScore(b, query) - relevanceScore(a, query));82 },83 };8485 Comlink.expose(api);8687 // main.ts — call worker functions like normal async functions88 import * as Comlink from 'comlink';8990 const worker = new Worker(new URL('./worker.ts', import.meta.url), {91 type: 'module',92 });93 const api = Comlink.wrap<typeof import('./worker').api>(worker);9495 // Looks like a regular async function call96 const processed = await api.processData(largeDataset);97 const results = await api.search(items, 'query');98 ```991003. **Use Transferable objects for zero-copy data transfer.** Large ArrayBuffers can be transferred to workers without copying:101102 ```typescript103 // Transfer (zero-copy) — ownership moves to worker, original is detached104 const buffer = new ArrayBuffer(1024 * 1024); // 1MB105 worker.postMessage({ buffer }, [buffer]);106 // buffer.byteLength is now 0 — ownership transferred107108 // Structured clone (copy) — default behavior, copies data109 worker.postMessage({ data: largeArray });110 // Both threads have their own copy — 2x memory usage111112 // Transfer ImageBitmap for image processing113 const bitmap = await createImageBitmap(imageBlob);114 worker.postMessage({ bitmap }, [bitmap]);115 ```1161174. **Implement a worker pool for parallel throughput.** Create `navigator.hardwareConcurrency` workers at startup. Maintain a busy set and task queue. When a task arrives, dispatch to an idle worker or enqueue. On completion, resolve the promise and process the next queued task:118119 ```typescript120 class WorkerPool {121 private workers: Worker[] = [];122 private queue: Array<{ task: any; resolve: Function; reject: Function }> = [];123 private busy = new Set<Worker>();124125 constructor(workerUrl: URL, poolSize = navigator.hardwareConcurrency || 4) {126 for (let i = 0; i < poolSize; i++) {127 const w = new Worker(workerUrl, { type: 'module' });128 w.addEventListener('message', (e) => this.onComplete(w, e.data));129 this.workers.push(w);130 }131 }132133 exec(task: any): Promise<any> {134 return new Promise((resolve, reject) => {135 const idle = this.workers.find((w) => !this.busy.has(w));136 if (idle) {137 this.dispatch(idle, task, resolve, reject);138 } else {139 this.queue.push({ task, resolve, reject });140 }141 });142 }143144 private dispatch(w: Worker, task: any, resolve: Function, reject: Function) {145 this.busy.add(w);146 (w as any).__resolve = resolve;147 w.postMessage(task);148 }149150 private onComplete(w: Worker, result: any) {151 (w as any).__resolve(result);152 this.busy.delete(w);153 const next = this.queue.shift();154 if (next) this.dispatch(w, next.task, next.resolve, next.reject);155 }156157 terminate() {158 this.workers.forEach((w) => w.terminate());159 }160 }161 ```1621635. **Use SharedArrayBuffer for real-time shared state.** Requires COOP (`same-origin`) and COEP (`require-corp`) headers. Create a `SharedArrayBuffer`, wrap in `Int32Array`, and send to multiple workers. Use `Atomics.add/load/store` for thread-safe reads and writes, and `Atomics.wait/notify` for synchronization. This avoids all serialization overhead for numeric data.1641656. **Integrate workers with React.** Create a `useWorker` hook: instantiate the worker in `useEffect`, return `{ result, loading, execute }`, and terminate on cleanup. This manages lifecycle and prevents leaks:166167 ```typescript168 function useWorker<T>(workerFactory: () => Worker) {169 const workerRef = useRef<Worker | null>(null);170 const [result, setResult] = useState<T | null>(null);171 const [loading, setLoading] = useState(false);172173 useEffect(() => {174 workerRef.current = workerFactory();175 workerRef.current.addEventListener('message', (e) => {176 setResult(e.data);177 setLoading(false);178 });179 return () => workerRef.current?.terminate();180 }, []);181182 const execute = useCallback((data: any) => {183 setLoading(true);184 workerRef.current?.postMessage(data);185 }, []);186187 return { result, loading, execute };188 }189 ```1901917. **Configure bundlers for worker support.** Vite supports `import MyWorker from './worker?worker'` or the standard `new URL('./worker.ts', import.meta.url)` pattern. Webpack 5 and esbuild also support the `new URL()` pattern natively (worker-loader is no longer needed).192193## Details194195### Worker Thread Cost196197Worker creation takes ~40-100ms; each consumes ~1-5MB for its V8 isolate. Structured clone serialization runs at ~400MB/s for typed arrays, ~50MB/s for complex objects. A 10MB JSON dataset takes ~200ms to serialize, potentially negating the benefit. Use Transferable objects or SharedArrayBuffer to avoid copy cost.198199### Worked Example: Figma Canvas Rendering200201A dedicated worker parses the binary file format, computes layout constraints, and generates render commands sent to the main thread via Transferable ArrayBuffers for WebGL submission. Result: opening 10,000+ layer files does not block UI; INP stays under 50ms.202203### Worked Example: Google Sheets Calculations204205Cell recalculation (dependency graph traversal + formula evaluation) runs in a worker pool. SharedArrayBuffer stores the cell value grid so all workers read current state without serialization. Result: responsive typing and scrolling even during heavy recalculation.206207### Anti-Patterns208209**Moving trivial computation to workers.** If the computation takes <5ms, the overhead of postMessage serialization (~1ms) and worker context switching exceeds the benefit. Only offload computation that takes >50ms on the main thread.210211**Creating a new worker per task.** Worker creation takes ~50ms. Reuse workers by sending new tasks via postMessage. Create workers at application startup, not on demand.212213**Sending large objects via postMessage without Transferable.** Sending a 50MB ArrayBuffer via structured clone takes ~125ms and doubles memory usage. Use Transferable objects (`postMessage(data, [buffer])`) for zero-copy transfer.214215**Ignoring worker errors.** Uncaught errors in workers are silently swallowed by default. Always add `onerror` and `onmessageerror` handlers to workers for debugging and resilience.216217## Source218219- MDN: Web Workers API — https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API220- Comlink — https://github.com/GoogleChromeLabs/comlink221- MDN: SharedArrayBuffer — https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/SharedArrayBuffer222- Surma: "When should you be using Web Workers?" — https://surma.dev/things/when-workers/223224## Process2252261. Read the instructions and examples in this document.2272. Apply the patterns to your implementation, adapting to your specific context.2283. Verify your implementation against the details and edge cases listed above.229230## Harness Integration231232- **Type:** knowledge — this skill is a reference document, not a procedural workflow.233- **No tools or state** — consumed as context by other skills and agents.234235## Success Criteria236237- CPU-intensive tasks (>50ms) run in Web Workers, not on the main thread.238- Worker communication uses Comlink or a similar RPC abstraction for ergonomics.239- Large data transfers use Transferable objects to avoid serialization overhead.240- Workers are reused (not created per task) and properly terminated on cleanup.241- Main thread INP remains under 200ms even during heavy background computation.