Actionable rules for building, integrating, and maintaining Web Workers in modern TypeScript/React projects to keep the UI thread free and fast.
Your React app is stuttering. Users tap buttons and wait. Scroll feels janky. Data processing blocks the UI thread while your users wonder if the app crashed.
Web Workers solve this immediately by moving CPU-intensive work to background threads, keeping your UI buttery smooth while handling complex computations, large data parsing, and heavy algorithmic tasks.
Every millisecond your main thread spends on non-UI work is a millisecond users can't interact with your app. Common culprits that kill performance:
React's concurrent features help, but they can't solve CPU-bound tasks that simply take too long to execute.
These Cursor Rules transform Web Workers from a complex browser API into a streamlined, type-safe development pattern. You get:
Immediate UI responsiveness - Heavy computations run in parallel while users interact normally
Type-safe message contracts - No more guessing what data workers expect or return
Automatic error boundaries - Workers fail gracefully without crashing your app
Memory-efficient transfers - Large datasets move without expensive cloning
Production-ready patterns - Reusable hooks and battle-tested architectural decisions
// This freezes your UI for seconds
function processLargeDataset(data: unknown[]) {
return data.map(item => heavyComputation(item)); // 🔥 UI blocked
}
// UI stays responsive, computation happens in parallel
const processData = useWorker<ProcessRequest, ProcessResult>(
new URL('./data-processor.worker.ts', import.meta.url)
);
const handleData = async (data: unknown[]) => {
startTransition(async () => {
const result = await processData({ type: 'PROCESS', payload: data });
setResults(result.payload);
});
};
Measurable Impact:
Transform CSV imports, API responses, or user uploads without freezing the interface:
// src/workers/csv-parser.worker.ts
export type MainToWorker =
| { type: 'PARSE_CSV'; payload: string }
| { type: 'ABORT' };
export type WorkerToMain =
| { type: 'RESULT'; payload: Record<string, unknown>[] }
| { type: 'PROGRESS'; payload: number }
| { type: 'ERROR'; payload: string };
self.addEventListener('message', (ev: MessageEvent<MainToWorker>) => {
try {
if (ev.data.type === 'PARSE_CSV') {
const rows = parseCSVChunks(ev.data.payload, (progress) => {
self.postMessage({ type: 'PROGRESS', payload: progress });
});
self.postMessage({ type: 'RESULT', payload: rows });
}
} catch (e) {
self.postMessage({ type: 'ERROR', payload: (e as Error).message });
}
});
Handle canvas operations, filters, or computer vision without stuttering:
// Transfer ImageData efficiently
const processImage = useWorker<ImageRequest, ImageResult>(
new URL('./image-processor.worker.ts', import.meta.url)
);
const applyFilter = async (imageData: ImageData) => {
const buffer = imageData.data.buffer.slice(); // Clone for transfer
const result = await processImage(
{ type: 'APPLY_FILTER', payload: buffer },
[buffer] // Transfer ownership to worker
);
return new ImageData(result.payload, imageData.width, imageData.height);
};
Combine WASM with workers for near-native performance:
// src/workers/wasm-compute.worker.ts
let wasmModule: WebAssembly.Module;
self.addEventListener('message', async (ev: MessageEvent<MainToWorker>) => {
if (ev.data.type === 'COMPUTE') {
if (!wasmModule) {
const wasmBinary = await fetch('./compute.wasm');
wasmModule = await WebAssembly.compile(await wasmBinary.arrayBuffer());
}
const instance = await WebAssembly.instantiate(wasmModule);
const result = instance.exports.compute(ev.data.payload);
self.postMessage({ type: 'RESULT', payload: result });
}
});
Create your worker directory structure:
src/
workers/
json-parser.worker.ts
image-processor.worker.ts
csv-parser.worker.ts
hooks/
useWorker.ts
Define your worker's communication protocol:
// src/workers/types.ts
export type ParseRequest = { type: 'PARSE_JSON'; payload: string };
export type ParseResponse =
| { type: 'RESULT'; payload: Record<string, unknown> }
| { type: 'ERROR'; payload: string };
Build a reusable React hook for worker management:
// src/hooks/useWorker.ts
export function useWorker<Req, Res>(url: URL) {
const workerRef = useRef<Worker>();
useEffect(() => {
workerRef.current = new Worker(url, { type: 'module' });
return () => workerRef.current?.terminate();
}, [url]);
return useCallback(
(msg: Req, transfer?: Transferable[]) =>
new Promise<Res>((resolve, reject) => {
const worker = workerRef.current!;
worker.onmessage = e => resolve(e.data as Res);
worker.onerror = e => reject(new Error(e.message));
worker.postMessage(msg, transfer ?? []);
}),
[]
);
}
For Vite projects, ensure worker support:
// vite.config.ts
import { defineConfig } from 'vite';
export default defineConfig({
worker: {
format: 'es'
},
optimizeDeps: {
exclude: ['**/*.worker.ts']
}
});
Track worker performance in development:
// src/utils/worker-monitor.ts
export function monitorWorker(worker: Worker, taskName: string) {
if (process.env.NODE_ENV === 'development') {
const startTime = performance.now();
worker.addEventListener('message', () => {
const duration = performance.now() - startTime;
console.debug(`Worker ${taskName} completed in ${duration.toFixed(2)}ms`);
});
}
}
Start with one CPU-intensive task in your app - JSON parsing, image processing, or data transformation. Implement it as a Web Worker using these patterns, and you'll immediately feel the difference in your app's responsiveness. Your users will notice the smooth interactions, and you'll wonder how you ever shipped without background processing.
You are an expert in JavaScript (ES2023), TypeScript 5.x, Web Workers API (Dedicated, Shared, Service, Worklet), React 18, and modern build tooling (Vite, Webpack 5).
Key Principles
- Off-load CPU-intensive tasks (parsing, image filtering, algorithmic crunching) to background threads immediately.
- Keep the main thread exclusively for UI and minimal orchestration; never block rendering.
- Design a strict, typed, message-passing contract; prefer immutable data.
- Minimise memory churn: reuse worker instances, use transferable objects, terminate when idle.
- Handle errors early and visibly in development; fail gracefully in production.
- All code samples and files are written in TypeScript modules.
- Directory convention: `src/workers/<task-name>.worker.ts` for each dedicated task.
TypeScript / JavaScript Rules
- Always create workers with module syntax:
```ts
const worker = new Worker(new URL('./json-parser.worker.ts', import.meta.url), { type: 'module' });
```
- Define a discriminated-union protocol:
```ts
export type MainToWorker =
| { type: 'PARSE_JSON'; payload: string }
| { type: 'ABORT' };
export type WorkerToMain =
| { type: 'RESULT'; payload: Record<string, unknown> }
| { type: 'ERROR'; payload: string };
```
- Use `postMessage(msg, transfer)`; pass `ArrayBuffer`, `MessagePort`, `ImageBitmap` via the `transfer` list to avoid cloning.
- Guard every incoming message:
```ts
function isParseJson(m: MainToWorker): m is { type: 'PARSE_JSON'; payload: string } {
return m.type === 'PARSE_JSON' && typeof m.payload === 'string';
}
```
- Cancel long tasks with `AbortController`:
```ts
const ctrl = new AbortController();
worker.postMessage({ type: 'ABORT' });
ctrl.abort();
```
- Never access the DOM inside a worker; keep logic pure and stateless.
Error Handling and Validation
- Feature-detect support:
```ts
if (typeof Worker === 'undefined') throw new Error('Web Workers unsupported');
```
- Inside the worker, wrap the root handler:
```ts
self.addEventListener('message', (ev: MessageEvent<MainToWorker>) => {
try {
// validate + handle
} catch (e) {
self.postMessage({ type: 'ERROR', payload: (e as Error).message });
}
});
self.addEventListener('error', e => {
self.postMessage({ type: 'ERROR', payload: e.message });
});
```
- Provide a synchronous fallback (on the main thread) for environments lacking workers.
- Log worker lifecycle events (`online`, `message`, `error`, `exit`) to `console.debug` in dev builds.
React-Specific Rules
- Encapsulate worker usage in a reusable hook:
```ts
export function useWorker<Req, Res>(url: URL) {
const workerRef = useRef<Worker>();
useEffect(() => {
workerRef.current = new Worker(url, { type: 'module' });
return () => workerRef.current?.terminate();
}, [url]);
return useCallback(
(msg: Req, transfer?: Transferable[]) =>
new Promise<Res>(res => {
workerRef.current!.onmessage = e => res(e.data as Res);
workerRef.current!.postMessage(msg, transfer ?? []);
}),
[]
);
}
```
- Use `startTransition` or `useTransition` when awaiting worker results to keep UI interactions high-priority.
- For shared computations across tabs (e.g., WebSocket parsing), prefer `SharedWorker` and coordinate via `BroadcastChannel`.
Testing
- Unit test worker logic with Jest + `jest-worker`:
```ts
import Worker from 'jest-worker';
const parser = new Worker(require.resolve('../src/workers/json-parser.worker.ts'));
expect(await parser.parse('{"a":1}')).toEqual({ a: 1 });
```
- Use Chrome DevTools → Performance panel; ensure ≥ 80 % of heavy CPU time runs in workers.
- Automate Lighthouse CI budgets: main-thread blocking time < 50 ms per interaction.
Performance Optimisation
- Batch messages; send arrays of tasks instead of per-item chatter when throughput matters.
- Split very large tasks inside the worker using `setTimeout`/`setInterval` to yield back to the event loop on older CPUs.
- Keep worker bundles < 50 kB gzip; lazy-load additional code with dynamic `import()` inside the worker.
- Profile memory; free large `ArrayBuffer`s by zeroing references after `postMessage`.
Security Rules
- Only import libraries from same-origin or a vetted CDN inside workers.
- Sanitize any user-provided data even in a worker; never assume background code is safe.
- When performing `fetch` inside a worker, set `mode: 'cors'` and validate content-type before processing.
Naming & File Organisation
- Worker files: `*.worker.ts` suffix.
- Colocate test files next to worker: `json-parser.worker.test.ts`.
- Glob imports in build config: `viteWorker: /\.worker\.ts$/` to enable separate bundles.
Lifecycle Management
- Reuse workers for batch jobs (`queue` inside worker).
- Terminate immediately after `RESULT` if one-shot: `self.close()`.
- Expose a `PING` message to measure latency and detect stalled threads.
Tooling & Build
- Use `vite-plugin-worker` or Webpack `worker-loader` with `esModule: true`.
- Set `tsconfig.json` → `lib: ["DOM", "DOM.Iterable", "WebWorker"]`.
- Enable `moduleResolution: "bundler"` for proper URL import handling.
Additional Tips
- Combine Web Workers with WebAssembly for near-native compute; instantiate WASM module inside the worker.
- For third-party scripts (analytics), consider Partytown to relocate them to workers automatically.
- Monitor worker count; cap at `navigator.hardwareConcurrency - 1` to avoid CPU saturation.