Web Workers in Practice: Safely Offloading Heavy Compute in the Browser
Why Does the Main Thread Stutter?
The browser’s main thread handles JavaScript execution, DOM rendering, events, and layout. When a JS task runs longer than 50ms, users feel jank—scrolling lags, clicks don’t respond, animations drop frames.
Typical long-running tasks:
| Scenario | Duration | Main-thread impact |
|---|---|---|
| PDF page-by-page render to images | 2–30s | Page fully frozen |
| FFmpeg video transcoding | 10–120s | No cancel, no UI feedback |
| Batch PNG compression | 1–10s | Inputs unresponsive |
| Large JSON formatting | 100–500ms | Brief stutter |
The core value of Web Workers: move compute-heavy work off the main thread so the UI stays interactive.
Web Worker Basics
Thread model
┌─────────────────────────────────┐
│ Main Thread │
│ DOM · Event Loop · Rendering │
│ ↕ postMessage │
├─────────────────────────────────┤
│ Web Worker │
│ Separate JS context · no DOM │
│ Separate Event Loop │
└─────────────────────────────────┘
Workers have their own JavaScript environment but cannot access DOM, window, or document. Data flows via postMessage; large objects use Transferable Objects for zero-copy transfer.
Two ways to create a Worker
// Option 1: Separate file (recommended, better code splitting)
const worker = new Worker(new URL('./pdf-worker.ts', import.meta.url));
// Option 2: Blob URL (good for dynamic code)
const blob = new Blob([workerCode], { type: 'application/javascript' });
const worker = new Worker(URL.createObjectURL(blob));
Next.js / Vite recommend new URL(..., import.meta.url) so the bundler handles Worker chunks and hashes.
Messaging: postMessage and Transferables
Structured clone vs transfer ownership
// ❌ Copy: large ArrayBuffer is duplicated—double memory
worker.postMessage({ buffer: largeArrayBuffer });
// ✅ Transfer: zero-copy; main thread loses access immediately
worker.postMessage({ buffer: largeArrayBuffer }, [largeArrayBuffer]);
For PDF binary data (often 1–50MB), Transferables are essential. ToolsKu’s PDF-to-image flow avoids 200MB+ of copies on a 100-page PDF by transferring buffers.
Request–response pattern
// main.ts
const id = crypto.randomUUID();
worker.postMessage({ type: 'render', id, pageIndex: 0, pdfBytes }, [pdfBytes]);
worker.onmessage = (e) => {
if (e.data.id === id) {
const imageBlob = e.data.result;
displayPreview(imageBlob);
}
};
Use UUIDs to pair requests and responses and process multiple pages concurrently.
Worker Architecture in ToolsKu
PDF to images: parallel Worker pool
User uploads PDF
↓
Main thread: parse page count
↓
Create Worker pool (CPU cores − 1)
↓
Dispatch per-page render → Worker 1, 2, 3, 4...
↓
Main thread: collect results → live progress bar
↓
ZIP download
Worker count is usually navigator.hardwareConcurrency - 1, balancing parallelism and memory.
FFmpeg.wasm: single Worker + SharedArrayBuffer
Video transcoding uses FFmpeg.wasm—a large WASM module (~25MB). WASM instances can’t be shared across Workers, so video tools use a dedicated single Worker:
- Main thread: UI state, progress, cancel
- Worker: FFmpeg encode, progress callbacks
SharedArrayBufferfor shared progress (requires COOP/COEP headers)
Errors and Worker lifecycle
worker.onerror = (e) => {
console.error('Worker error:', e.message);
worker.terminate();
fallbackToMainThread(); // fall back to main thread
};
// Terminate when done to free memory
worker.terminate();
Workers aren’t auto-GC’d—long-lived Workers keep memory. Call terminate() after tasks or release large objects inside the Worker.
Limits and Alternatives
| Limit | Impact | Alternative |
|---|---|---|
| No DOM | Can’t touch UI | postMessage results back |
| No localStorage | No persistence | IndexedDB (Workers can use it) |
| ~50ms startup | Short tasks not worth it | Keep tasks < 50ms on main thread |
| Hard to debug | Limited DevTools | console.log + Worker panel |
For very short work (e.g. JSON format < 10KB), startup can exceed compute. ToolsKu enables Workers when file > 1MB or estimated time > 100ms.
Benchmarks
Chrome 130, M2 MacBook Pro, 50-page PDF to PNG:
| Approach | Total time | Main-thread block | Interactive |
|---|---|---|---|
| Main thread sync | 18.2s | 18.2s | ❌ |
| Single Worker | 17.8s | 0s | ✅ |
| 4 Workers parallel | 5.1s | 0s | ✅ |
Parallel Workers cut time from 18s to 5s while the UI stays responsive—users can cancel, navigate, and preview finished pages.
Best Practices
- Enable when needed: don’t Worker-ize tiny tasks
- Prefer Transferables: must transfer large binary ownership
- Worker pool: CPU-bound parallelism; single Worker for I/O-bound
- Terminate promptly:
terminate()on complete or cancel - Fallback: if Workers unavailable, main thread +
requestIdleCallback - Progress: long jobs must postMessage progress so users know it’s alive
Web Workers are core infrastructure for high-performance browser tools. With WASM and Transferables, multi-GB files in the browser are feasible—the foundation for ToolsKu’s 200+ tools running entirely locally.
Try these browser-local tools — no sign-up required →