Best Practices

AI Rules for Web Workers

AI runs heavy computation on the main thread, blocking the UI. Rules for offloading work to Web Workers, structured cloning, Comlink for ergonomic messaging, shared workers, and worker pool patterns.

7 min read·April 1, 2025

100,000 items sorted on the main thread — 200ms of frozen UI, clicks queued, animations stopped

Worker offloading, Comlink RPC, worker pools, SharedWorker cross-tab, Transferable zero-copy

AI Blocks the Main Thread with Heavy Work

AI generates client-side code with: heavy computation on the main thread (sorting 100,000 items, parsing large JSON, image processing in canvas — all blocking the UI), no awareness of the 50ms long task threshold (anything over 50ms blocks user interactions), complex postMessage serialization (manual JSON stringify/parse for every worker message), no worker reuse (creating and destroying workers per operation — each creation has startup cost), and no shared state (duplicate state across tabs because each tab has its own worker). The user clicks a button, the UI freezes for 2 seconds, and they think the app is broken.

Modern Web Worker usage is: main-thread-protective (any operation over 50ms moves to a worker), Comlink-wrapped (RPC-style calls instead of raw postMessage), pool-managed (reusable worker pool for parallel operations), SharedWorker-connected (cross-tab shared state and communication), and Transferable-optimized (zero-copy transfer for large data like ArrayBuffers). AI generates none of these.

These rules cover: identifying main thread bottlenecks, Comlink for ergonomic worker messaging, worker pool patterns, SharedWorker for cross-tab state, Transferable objects for zero-copy data transfer, and bundler configuration for workers.

Rule 1: Offload Heavy Computation to Workers

The rule: 'Move any operation that takes more than 50ms off the main thread. Common candidates: sorting or filtering large datasets (10,000+ items), JSON parsing of large payloads (1MB+), cryptographic operations (hashing, encryption), image manipulation (canvas pixel operations), text parsing (markdown to HTML, CSV parsing), and data transformation (aggregation, grouping, pivoting). The 50ms threshold comes from the Long Tasks API — tasks over 50ms are flagged as blocking interactions and hurt the INP Core Web Vital.'

For the worker creation: 'const worker = new Worker(new URL("./sort-worker.ts", import.meta.url), { type: "module" }). In the worker file: self.onmessage = (event) => { const sorted = event.data.sort(compareFn); self.postMessage(sorted); }. Main thread: worker.postMessage(largeArray); worker.onmessage = (event) => { setItems(event.data); }. The sort runs in a separate thread. The main thread stays responsive. The user can scroll, click, and type while 100,000 items sort in the background.'

AI generates: const sorted = largeArray.sort(compareFn); setItems(sorted); — synchronous sort on the main thread. 100,000 items: 200ms blocked. The user clicks a button during the sort: the click is queued and processed 200ms later (or dropped). The UI feels frozen. With a worker: the sort takes the same 200ms but the main thread is free. Clicks, scrolls, and animations continue uninterrupted. Same operation, zero UI blocking.

  • 50ms threshold: anything longer blocks interactions and hurts INP
  • Common offloads: sort, filter, parse JSON, encrypt, image manipulation, CSV
  • new Worker(new URL('./worker.ts', import.meta.url)): module worker with bundler support
  • Main thread stays responsive: clicks, scrolls, animations uninterrupted
  • Performance budget: main thread work < 50ms per task, everything else in workers

Rule 3: Worker Pool for Parallel Processing

The rule: 'For repeated operations, use a worker pool instead of creating workers on demand. Creating a worker: 50-100ms startup time (loading and parsing the script). Worker pool: create N workers at app startup (N = navigator.hardwareConcurrency or 4), distribute tasks round-robin. Library: workerpool (npm) or a simple custom pool. Pattern: const pool = new WorkerPool(4); const results = await Promise.all(chunks.map(chunk => pool.run("process", chunk))). Four chunks processed in parallel across 4 workers — 4x throughput on a 4-core device.'

For chunk sizing: 'Split large datasets into chunks matching the pool size. 100,000 items with 4 workers: 4 chunks of 25,000 items. Each worker processes one chunk. Total time: max(chunk times), not sum. With a single worker: 200ms. With 4 workers: 60ms (not exactly 50ms due to overhead, but close to linear speedup). Diminishing returns beyond navigator.hardwareConcurrency: more workers than CPU cores means context switching, not parallelism.'

AI generates: new Worker() for each operation, immediately terminated after use. Processing 10 CSV files: 10 workers created (500ms startup overhead), 10 files processed (500ms), 10 workers terminated. Total: 1000ms. Worker pool with 4 workers: 0ms startup (already running), 10 files processed across 4 workers in 3 rounds (375ms). Total: 375ms. 2.7x faster from eliminating worker creation overhead alone.

  • Pool of N workers: N = navigator.hardwareConcurrency (typically 4-8)
  • Reuse workers: 0ms startup vs 50-100ms per new Worker() creation
  • Chunk and distribute: split data into N chunks, one per worker, parallel processing
  • Linear speedup: 4 workers on 4 cores = ~4x throughput for CPU-bound work
  • workerpool library or custom round-robin pool — distribute tasks evenly
⚠️ 2.7x Faster from Reusing Workers

Creating 10 workers for 10 files: 500ms startup overhead + 500ms processing = 1000ms. Pool of 4 reused workers: 0ms startup + 375ms processing (3 rounds of 4) = 375ms. Worker creation is the bottleneck — pool eliminates it entirely.

Rule 4: SharedWorker for Cross-Tab Communication

The rule: 'Use SharedWorker when multiple tabs need shared state or communication. A regular Worker: one per tab, each with its own state. A SharedWorker: one instance shared across all tabs of the same origin. Use cases: cross-tab notification badge (update count in all tabs when a notification arrives), shared WebSocket connection (one connection instead of one per tab), synchronized state (shopping cart updated in all open tabs simultaneously), and auth state (logout in one tab logs out all tabs).'

For the SharedWorker API: 'Main thread: const worker = new SharedWorker(new URL("./shared.ts", import.meta.url)); worker.port.onmessage = handler; worker.port.postMessage(data). Worker: self.onconnect = (e) => { const port = e.ports[0]; ports.push(port); port.onmessage = (event) => { ports.forEach(p => p.postMessage(event.data)); }; }. The worker maintains a list of connected ports (one per tab). Broadcasting: send to all ports. The SharedWorker is the cross-tab event bus.'

AI generates: BroadcastChannel for cross-tab communication (simpler but no shared state, no connection management) or localStorage events (hack-ish, synchronous, limited to string data). SharedWorker: maintains shared state in memory, processes logic once (not per tab), and manages a shared WebSocket connection. One WebSocket connection for 5 tabs instead of 5 connections. One notification check for 5 tabs instead of 5 polling loops.

Rule 5: Transferable Objects for Zero-Copy Transfer

The rule: 'When sending large data (ArrayBuffer, ImageBitmap, OffscreenCanvas) to a worker, use Transferable objects instead of structured clone. Structured clone: copies the data (10MB ArrayBuffer = 10MB copy = 20MB total memory, plus copy time). Transferable: transfers ownership (10MB moves to the worker, the main thread reference becomes detached = 10MB total, zero copy time). Syntax: worker.postMessage(buffer, [buffer]) — the second argument lists Transferable objects.'

For when to use Transferable: 'Use for: ArrayBuffers over 1MB (image data, audio data, binary files), OffscreenCanvas (render in worker, transfer the canvas), ImageBitmap (image processing pipeline in worker). Do not use for: small data (structured clone overhead is negligible under 1MB), data needed by both threads (transfer makes the original unusable), and non-Transferable types (regular objects, arrays, strings — these are always structured-cloned).'

AI generates: worker.postMessage(largeImageBuffer) — structured clone copies 10MB of image data. The copy takes 50ms and doubles memory usage. With transfer: worker.postMessage(largeImageBuffer, [largeImageBuffer]) — zero copy, zero extra memory, instant transfer. The main thread ArrayBuffer becomes detached (length 0) — the worker now owns it. For image processing pipelines: transfer the buffer to the worker, process, transfer the result back. Zero copies throughout.

ℹ️ 10MB in 0ms vs 50ms

Structured clone: copies 10MB ArrayBuffer (50ms copy time, 20MB total memory). Transferable: transfers ownership in 0ms (10MB total, zero copy). worker.postMessage(buffer, [buffer]) — the second argument makes the difference. Main thread buffer becomes detached.

Complete Web Workers Rules Template

Consolidated rules for Web Workers.

  • Offload operations > 50ms: sort, filter, parse, encrypt, image manipulation
  • Comlink: RPC-style worker API — await worker.sort(data) instead of postMessage/onmessage
  • Worker pool: N = hardwareConcurrency, reuse workers, chunk and distribute for parallelism
  • SharedWorker: cross-tab shared state, single WebSocket, synchronized notifications
  • Transferable objects: zero-copy ArrayBuffer transfer — 10MB in 0ms vs 50ms structured clone
  • Module workers: new Worker(new URL('./worker.ts', import.meta.url), { type: 'module' })
  • Performance budget: main thread < 50ms per task, INP-friendly, worker handles the rest
  • Linear speedup: 4 workers on 4 cores = ~4x throughput for CPU-bound work