Worker Patterns in Production
The Pattern Nobody Teaches
Every Web Worker tutorial shows the same thing: create a worker, send a message, get a result. Then they stop. But in production, you need answers to questions those tutorials never ask:
- What happens when the worker throws? Does the user see a blank screen?
- How many workers should you create? One? Eight?
navigator.hardwareConcurrency? - How do you handle the case where the user navigates away mid-computation?
- How do you integrate workers with React's rendering model?
- What about the mobile user on a 2-core phone — does your 8-worker pool make things worse?
These aren't edge cases. They're the difference between a demo that works on your M3 MacBook and a system that works on a budget Android phone with 2GB RAM.
Production worker architecture is like running a restaurant kitchen. You don't just need chefs (workers) — you need a system: an order queue (task scheduling), a way to handle a chef calling in sick (error recovery), a policy for when the kitchen is full (backpressure), a plan for slow nights (dynamic scaling), and a way to close for the night without losing any orders (graceful shutdown). The cooking itself is the easy part. The orchestration is what separates a food truck from a Michelin-star restaurant.
Pattern 1: Typed Message Protocol
Raw postMessage becomes unmaintainable when workers handle multiple operations. Define a typed protocol:
// shared/worker-protocol.ts
type WorkerRequest =
| { type: 'SEARCH'; id: string; query: string; limit: number }
| { type: 'PROCESS'; id: string; buffer: ArrayBuffer }
| { type: 'CANCEL'; id: string }
| { type: 'SHUTDOWN' };
type WorkerResponse =
| { type: 'SEARCH_RESULT'; id: string; results: SearchResult[] }
| { type: 'PROCESS_RESULT'; id: string; buffer: ArrayBuffer }
| { type: 'ERROR'; id: string; message: string; stack?: string }
| { type: 'READY' }
| { type: 'SHUTDOWN_COMPLETE' };
Every request includes an id for correlating responses. This lets you have multiple in-flight operations and match each response to its caller:
// main.js
class TypedWorker {
#worker;
#pending = new Map();
#nextId = 0;
constructor(url) {
this.#worker = new Worker(url, { type: 'module' });
this.#worker.onmessage = (event) => this.#handleMessage(event.data);
this.#worker.onerror = (event) => this.#handleError(event);
}
request(type, payload) {
const id = String(this.#nextId++);
return new Promise((resolve, reject) => {
this.#pending.set(id, { resolve, reject });
this.#worker.postMessage({ type, id, ...payload });
});
}
cancel(id) {
this.#worker.postMessage({ type: 'CANCEL', id });
const pending = this.#pending.get(id);
if (pending) {
pending.reject(new DOMException('Cancelled', 'AbortError'));
this.#pending.delete(id);
}
}
#handleMessage(data) {
if (data.type === 'ERROR') {
const pending = this.#pending.get(data.id);
if (pending) {
pending.reject(new Error(data.message));
this.#pending.delete(data.id);
}
return;
}
const pending = this.#pending.get(data.id);
if (pending) {
pending.resolve(data);
this.#pending.delete(data.id);
}
}
#handleError(event) {
for (const [id, { reject }] of this.#pending) {
reject(new Error(event.message));
}
this.#pending.clear();
}
terminate() {
for (const [, { reject }] of this.#pending) {
reject(new DOMException('Worker terminated', 'AbortError'));
}
this.#pending.clear();
this.#worker.terminate();
}
}
Pattern 2: Adaptive Worker Pool
navigator.hardwareConcurrency returns the number of logical CPU cores. But creating that many workers isn't always optimal:
class AdaptivePool {
#workers = [];
#taskQueue = [];
#activeTasks = new Map();
#metrics = { completed: 0, totalTime: 0 };
constructor(url, options = {}) {
const cores = navigator.hardwareConcurrency || 4;
const maxWorkers = options.maxWorkers ?? Math.max(1, cores - 1);
const initialWorkers = options.initialWorkers ?? Math.min(2, maxWorkers);
this.#maxWorkers = maxWorkers;
this.#url = url;
for (let i = 0; i < initialWorkers; i++) {
this.#addWorker();
}
}
#maxWorkers;
#url;
#addWorker() {
if (this.#workers.length >= this.#maxWorkers) return;
const worker = new Worker(this.#url, { type: 'module' });
worker.idle = true;
worker.onmessage = (event) => this.#onResult(worker, event.data);
worker.onerror = (event) => this.#onError(worker, event);
this.#workers.push(worker);
}
async exec(data, options = {}) {
return new Promise((resolve, reject) => {
const task = {
data,
resolve,
reject,
startTime: 0,
signal: options.signal,
};
if (options.signal?.aborted) {
reject(new DOMException('Aborted', 'AbortError'));
return;
}
options.signal?.addEventListener('abort', () => {
reject(new DOMException('Aborted', 'AbortError'));
}, { once: true });
const idle = this.#workers.find((w) => w.idle);
if (idle) {
this.#dispatch(idle, task);
} else if (this.#workers.length < this.#maxWorkers) {
this.#addWorker();
const newWorker = this.#workers[this.#workers.length - 1];
this.#dispatch(newWorker, task);
} else {
this.#taskQueue.push(task);
}
});
}
#dispatch(worker, task) {
if (task.signal?.aborted) {
task.reject(new DOMException('Aborted', 'AbortError'));
this.#scheduleNext(worker);
return;
}
worker.idle = false;
task.startTime = performance.now();
this.#activeTasks.set(worker, task);
worker.postMessage(task.data);
}
#onResult(worker, data) {
const task = this.#activeTasks.get(worker);
if (task) {
this.#activeTasks.delete(worker);
this.#metrics.completed++;
this.#metrics.totalTime += performance.now() - task.startTime;
task.resolve(data);
}
this.#scheduleNext(worker);
}
#onError(worker, event) {
const task = this.#activeTasks.get(worker);
if (task) {
this.#activeTasks.delete(worker);
task.reject(new Error(event.message));
}
this.#scheduleNext(worker);
}
#scheduleNext(worker) {
const next = this.#taskQueue.shift();
if (next) {
this.#dispatch(worker, next);
} else {
worker.idle = true;
}
}
get stats() {
return {
workers: this.#workers.length,
idle: this.#workers.filter((w) => w.idle).length,
queued: this.#taskQueue.length,
avgTime: this.#metrics.completed
? this.#metrics.totalTime / this.#metrics.completed
: 0,
};
}
terminate() {
this.#workers.forEach((w) => w.terminate());
this.#workers = [];
for (const [, task] of this.#activeTasks) {
task.reject(new DOMException('Pool terminated', 'AbortError'));
}
this.#activeTasks.clear();
for (const task of this.#taskQueue) {
task.reject(new DOMException('Pool terminated', 'AbortError'));
}
this.#taskQueue = [];
}
}
Pattern 3: AbortController Integration
Every long-running worker task should support cancellation through AbortController:
const controller = new AbortController();
try {
const result = await pool.exec(
{ type: 'SEARCH', query: 'react patterns' },
{ signal: controller.signal }
);
displayResults(result);
} catch (err) {
if (err.name === 'AbortError') {
console.log('Search cancelled');
} else {
showError(err);
}
}
// User navigates away or types a new query
controller.abort();
This integrates cleanly with React's useEffect cleanup:
function useWorkerSearch(query) {
const [results, setResults] = useState(null);
const [loading, setLoading] = useState(false);
const poolRef = useRef(null);
if (!poolRef.current) {
poolRef.current = new AdaptivePool('/workers/search.js');
}
useEffect(() => {
if (!query) {
setResults(null);
return;
}
const controller = new AbortController();
setLoading(true);
poolRef.current
.exec({ type: 'SEARCH', query }, { signal: controller.signal })
.then((data) => {
setResults(data.results);
setLoading(false);
})
.catch((err) => {
if (err.name !== 'AbortError') {
setLoading(false);
console.error(err);
}
});
return () => controller.abort();
}, [query]);
return { results, loading };
}
Pattern 4: Worker Integration with Bundlers
In production, you need workers to be bundled, versioned, and code-split like any other module. Modern bundlers handle this:
Vite
const worker = new Worker(
new URL('./workers/search.js', import.meta.url),
{ type: 'module' }
);
Vite detects new URL('./path', import.meta.url) and bundles the worker script separately. It handles imports inside the worker, applies transforms, and outputs a hashed filename.
webpack 5
const worker = new Worker(
new URL('./workers/search.js', import.meta.url)
);
webpack 5+ uses the same new URL pattern. It creates a separate entry point for the worker and bundles its dependency tree independently.
Next.js
Next.js does not have built-in worker bundling in the App Router. The most reliable approach:
// Place worker files in the public directory
const worker = new Worker('/workers/search.js');
// Or use a bundler-aware approach with next.config
// by configuring webpack to handle worker entry points
For Next.js projects, Comlink with a worker in the public/ directory works well. Alternatively, configure webpack in next.config.js to handle worker entries:
// next.config.js
module.exports = {
webpack: (config, { isServer }) => {
if (!isServer) {
config.output.globalObject = 'self';
}
return config;
},
};
Always use new URL('./path', import.meta.url) for worker paths in Vite and webpack 5+. This pattern is statically analyzable by the bundler, ensuring the worker file is included in the build output with correct content hashing for cache busting.
Pattern 5: Graceful Degradation
Not every environment supports Workers (or the APIs they need). Build fallbacks:
function createSearchEngine(options = {}) {
const canUseWorker =
typeof Worker !== 'undefined' &&
(!options.needsSharedMemory || typeof SharedArrayBuffer !== 'undefined');
if (canUseWorker) {
return createWorkerSearch(options);
}
return createMainThreadSearch(options);
}
function createWorkerSearch(options) {
const pool = new AdaptivePool('/workers/search.js');
return {
async search(query) {
const result = await pool.exec({ type: 'SEARCH', query });
return result.results;
},
destroy() {
pool.terminate();
},
};
}
function createMainThreadSearch(options) {
return {
async search(query) {
await yieldToMain();
return searchSync(query, options.data);
},
destroy() {},
};
}
function yieldToMain() {
return new Promise((resolve) => setTimeout(resolve, 0));
}
The consumer doesn't know (or care) whether search runs in a worker or on the main thread. The API is identical.
Pattern 6: Error Recovery and Retry
Workers can crash. Script errors, memory exhaustion, or unhandled promise rejections can kill a worker. A production pool needs automatic recovery:
class ResilientWorkerPool {
#url;
#maxWorkers;
#workers = new Map();
#retryCount = new Map();
#maxRetries;
constructor(url, options = {}) {
this.#url = url;
this.#maxWorkers = options.maxWorkers ?? navigator.hardwareConcurrency - 1;
this.#maxRetries = options.maxRetries ?? 3;
}
#createWorker(id) {
const worker = new Worker(this.#url, { type: 'module' });
worker.onerror = () => {
const retries = this.#retryCount.get(id) ?? 0;
if (retries < this.#maxRetries) {
this.#retryCount.set(id, retries + 1);
console.warn(`Worker ${id} crashed. Restarting (attempt ${retries + 1})`);
this.#workers.delete(id);
this.#workers.set(id, this.#createWorker(id));
} else {
console.error(`Worker ${id} exceeded max retries. Removing from pool.`);
this.#workers.delete(id);
}
};
this.#workers.set(id, worker);
return worker;
}
}
Pattern 7: When Workers Hurt Performance
This might be the most important section. Workers are not always the answer. Here's when they make things worse:
Small tasks (under 5ms): The overhead of postMessage serialization + deserialization exceeds the computation time. You're adding latency, not removing it.
// DON'T use a worker for this
function capitalize(str) {
return str.charAt(0).toUpperCase() + str.slice(1);
}
// The postMessage round-trip takes longer than the function itself
Frequent tiny messages: If you're sending thousands of small messages per second, the serialization overhead compounds. Batch them:
// Bad: 1000 messages per second
data.forEach((item) => worker.postMessage(item));
// Good: 1 message with all data
worker.postMessage(data);
// Better: 1 message with transferred buffer
const encoded = encodeToBuffer(data);
worker.postMessage(encoded.buffer, [encoded.buffer]);
DOM-dependent computation: If the computation needs to read the DOM (element positions, computed styles, scroll positions), you have to serialize all that state and send it. The serialization cost often exceeds the computation:
// Bad: sending DOM state to a worker
const rects = elements.map((el) => el.getBoundingClientRect());
const styles = elements.map((el) => getComputedStyle(el));
// Serializing 1000 rects + styles might take longer than the layout calculation itself
Low-core devices: On a 2-core phone, creating 4 workers means the OS is context-switching between 5 threads (main + 4 workers) on 2 cores. Each context switch has overhead. You might get better performance with fewer workers:
const workerCount = Math.max(1, Math.min(
navigator.hardwareConcurrency - 1,
Math.floor(navigator.hardwareConcurrency / 2)
));
The biggest mistake in production worker usage isn't a code bug — it's not measuring. Always benchmark with and without workers on your target devices. A pattern that saves 200ms on a desktop can add 50ms on a phone. Use the Performance tab in DevTools to compare main-thread time, not just wall-clock time.
Production Checklist
Before shipping worker-based features, verify every item:
Performance
- Measured computation time exceeds 50ms on target devices (not just your MacBook)
- Structured clone cost for message payloads is less than 10% of computation time
- Worker pool size is adaptive based on
navigator.hardwareConcurrency - Transferable objects used for any
ArrayBufferover 1KB
Reliability
- Error handler registered on every worker (
onerror,onmessageerror) - Crashed workers are automatically replaced (retry with limits)
- All worker tasks support cancellation via
AbortController - Graceful shutdown protocol for clean teardown
Integration
- Main-thread fallback for environments without Worker support
- Worker scripts are bundled and versioned (cache-busting hashes)
- React hooks clean up worker tasks on unmount
- Memory leaks checked: workers are terminated when no longer needed
Testing
- Tested on low-end devices (2-core phone, throttled CPU in DevTools)
- Tested with large payloads (10x expected size)
- Tested with rapid cancellation (user navigating away mid-computation)
- Tested with multiple concurrent tasks
| What developers do | What they should do |
|---|---|
| Hardcoding worker pool size to 8 or navigator.hardwareConcurrency On a 2-core phone, 8 workers creates excessive context switching. On a 32-core workstation, 32 workers waste memory for CPU-bound tasks that do not benefit from more than 4-6 parallel streams. Always leave at least one core for the main thread. | Use cores - 1, cap at a reasonable maximum (4-6), and start with fewer workers, scaling up on demand |
| Not terminating workers when a component unmounts or a page navigates Unterminated workers continue consuming memory and CPU in the background. On mobile, this drains battery. In SPAs, navigating between pages can accumulate zombie workers. | Terminate workers in cleanup functions (React useEffect return, component destroy lifecycle) |
| Using workers for I/O-bound tasks like fetch or IndexedDB reads Fetch and IndexedDB do not block the main thread — they use the browser's I/O threads internally. Wrapping them in a worker adds postMessage overhead without any parallelism benefit. | Use workers for CPU-bound computation only. Fetch and IndexedDB are already asynchronous and non-blocking |
| Sending the same large dataset to a worker repeatedly If a search worker needs a 5MB index, send it once on startup and let the worker cache it. Subsequent search queries should only send the query string (a few bytes), not the entire index each time. | Send data once during initialization. Send only deltas or commands for subsequent operations |
Challenge: Production-Ready Worker Manager
Try to solve it before peeking at the answer.
// Design a WorkerManager class that provides:
// 1. Typed request/response protocol with correlation IDs
// 2. Adaptive pool sizing (start small, scale up on demand)
// 3. AbortController support for every task
// 4. Automatic worker restart on crash (with retry limit)
// 5. Main-thread fallback when Workers are unavailable
// 6. Pool statistics (active workers, queue depth, avg task time)
// 7. Clean shutdown (wait for in-flight tasks, then terminate)
//
// API:
// const manager = new WorkerManager('/worker.js', { maxWorkers: 4 });
// const result = await manager.exec('SEARCH', { query: 'test' });
// const stats = manager.stats;
// await manager.shutdown();
class WorkerManager {
// Your implementation
}Key Rules
- 1Use a typed message protocol with correlation IDs. Raw postMessage becomes unmaintainable past two message types.
- 2Size worker pools adaptively: start with 2 workers, scale to cores - 1. Never use all cores — the main thread needs one.
- 3Every worker task must support cancellation via AbortController. Uncancellable tasks leak resources when users navigate away.
- 4Always provide a main-thread fallback. Workers are not available in SSR, some WebViews, or under strict CSP.
- 5Measure on target devices before committing to workers. The overhead of serialization can exceed the computation benefit on tasks under 50ms.