WebWorker Performance
This simplified demo compares different approaches to handling CPU-intensive tasks using prime number calculation. Prime numbers are computationally expensive to find (growing increasingly more expensive the further we go), making them perfect for demonstrating performance differences. The spinning animation shows UI/Main thread responsiveness.
- Main Thread: Blocks UI during calculation (potentially crashing the tab)
- Single Worker: Keeps UI responsive with one background thread
- Multiple Workers: Distributes work across N workers managed by main thread
- Multiple Workers (managed): Uses a worker to manage other workers, reducing main thread overhead
- Main Thread (non-blocking): Uses setTimeout to compute only when the main thread is idle (in this case: between animation frames)
Main Thread (blocking)
Single Worker
Multiple Workers
Multiple Workers (managed)
Main Thread (non-blocking)
Expected Results
- While both Main Thread implementation are fastest on small data sizes, the non-blocking variant will be slowest when the "foreground" is busy running code (for example permanent canvas animations)
- As data size increases, the main thread blocking implementation will start to freeze the UI, making it unusable
- The single worker is comparable to the main thread, with a small added overhead for messaging
- Multiple workers will consistently outperform both main thread and single workers at a certain data size depending on the device
- "Managed" workers add an overhead similar to how main thread and single workers interact
Technical Details
1. Main Thread (blocking)
Implementation: Executes computation synchronously on the main JavaScript thread.
for (let i = 0; i < totalPrimes; i++) {
calculateNext();
updateProgress(i / totalPrimes);
}
If the dataset is large enough, the browser's UI freezes during calculation because the main thread is blocked (i.e. no additional code can be run, UI updates cannot be rendered) until all computations are finished.
Use case: Only acceptable for very short computations (<20ms).
2. Single WebWorker
Implementation: Offloads computation to one background worker thread.
const worker = new Worker('worker.js');
worker.onmessage = (event) => {
updateProgress(event.data);
};
worker.postMessage(totalPrimes);
UI remains responsive (60 FPS) while computation runs in parallel. The worker communicates progress via messages.
Use case: Perfect for single heavy tasks like image processing, data parsing, or complex calculations.
Performance: Usually fastest for CPU-bound tasks due to no coordination overhead.
3. Multiple Workers
Implementation: Splits work across N workers, with the main thread handling batching and messaging.
for (let i = 0; i < totalWorkers; i++) {
const worker = new Worker('worker.js');
worker.onmessage = (event) => {
updateProgress(aggregateResults(event.data));
};
worker.postMessage(createBatch(i, totalWorkers, totalPrimes));
}
Each worker handles a subset of the problem. Main thread aggregates progress and results.
Trade-offs: Can be faster with large datasets but adds coordination overhead to the main thread for each message sent/received.
Use case: Large datasets that can be parallelized (batch processing, parallel searches).
4. Multiple Workers managed by a Worker
Implementation: A "manager" worker creates and coordinates sub-workers, completely off the main thread.
// Main thread
const managerWorker = new Worker('manager.worker.js');
managerWorker.onmessage = (event) => {
updateProgress(event.data);
};
managerWorker.postMessage(totalPrimes);
// manager.worker.js
for (let i = 0; i < totalWorkers; i++) {
const worker = new Worker('sub.worker.js');
worker.onmessage = (event) => {
updateProgress(aggregateResults(event.data));
};
worker.postMessage(createBatch(i, totalWorkers, totalPrimes));
}
The Main thread only sends an initial request and receives the final result (and progress updates for the sake of this demo). All coordination happens in the background.
Benefits: Minimal main thread overhead, excellent for complex multi-step workflows or large datasets.
Use case: Complex background processing pipelines, data transformation chains, procedural generation.
5. Main Thread (non-blocking)
Implementation: Processes work asynchronous using setTimeout(0) or async functions.
function computePrimesAsync(startIndex) {
if (startIndex >= totalPrimes) {
return;
}
const prime = calculateNext();
updateProgress(startIndex / totalPrimes);
setTimeout(() => computePrimesAsync(startIndex + 1), 0);
}
computePrimesAsync(0);
UI stays responsive because the main thread periodically yields control for other tasks (rendering, user interactions). The animation stays smooth.
Trade-offs: Slower than synchronous execution due to yielding overhead, but much better user experience than blocking. Incredibly slow if the foreground is busy, for example when running canvas animations.
Use case: Good middle ground for medium-sized tasks that don't warrant WebWorker complexity. Significantly slower if the application generally is busy.
Conclusion
Performance Anti-Pattern
Never block the main thread. Even 20ms of synchronous work can cause noticeable UI stutters. Users expect at least 60 FPS (16.67ms per frame) for a smooth experience.
Single Worker Strategy
Start with one worker. For most CPU-intensive tasks, a single worker provides the best performance-to-complexity ratio. This is also a great opportunity to apply the single-responsibility pattern.
Parallelization Overhead
More workers ≠ better performance. Coordination overhead (handling messages, worker lifecycles) can outweigh benefits, especially for smaller tasks or when limited by other factors (few CPU cores). This must be tested against real-world data and devices.
Choose the Right Tool
• Short tasks (<10ms): Blocking implementation• Medium tasks (<100ms) and idle UI: Non-blocking implementation (setTimeout, async)
• Heavy computation or large dataset: 1 worker
• Heavy computation and large dataset: X workers
• Complex pipelines or applications: X workers managed by a dedicated worker
Real-World Applications
• 3D scene implementation (see OffscreenCanvas API)• Image/video/audio processing
• Large dataset analysis and visualization
• Cryptography and hashing
• Real-time data processing
Performance Monitoring
Always measure performance. Make use of browser dev tools like Chromium's performance and memory tabs. You could also implement modules in NodeJS and utilize more sophisticated tools in CI/CD pipelines before exporting to the client-side application.
Ideally you dont even need multiple workers and can stick to the main thread or a single worker. Optimize your algorithms and data structures first.
Additional Notes
- Workers have access to most relevant browser APIs. You could offload all state management, data fetching and processing to a worker and use the UI thread only for user interaction. Keep the messaging overhead in mind.
- When messaging data between threads, you can pass references (and ownership) instead of copies for certain data types such as Buffers (see Transferable objects). Doing this can mitigate performance impact on the main thread to ~0 when receiving data of any size.
- Message ports are transferable, allowing you to create pipelines of workers running in sequence, managed by a single thread instead of each other (see MessageChannel)
- You can use the Broadcast Channel API to streamline communication when using multiple workers
- Testing workers is tricky. Putting the main logic in modules that can also be imported in tests, while keeping glue code and internal complexity to a minimum is a solid approach.
- Implementing workers can be a difficult experience, especially for (frontend) developers not used to MultiThreading. Nowadays both Vite and Webpack provide great out-of-the-box support. Consider using Google's comlink library to create easy to use and type-safer APIs instead of having to send raw messages (though this increases overhead even further).