Web Workers and the Limits of Parallelism
Part 6 of 9: Series index
Eventually, every serious browser application hits the wall, where some piece of work needs more than one thread can offer. A cryptographic operation, an image filter, parsing a thirty-megabyte response, updating a search index. You can yield to the event loop as politely as you like, and you still won't finish before the user notices.
Workers are the answer, and the answer is more interesting than "spawn a thread." They're a deliberately isolated parallelism model, message-passing only, no shared DOM, no shared memory by default, and they come with an economy of their own. Using them well means knowing when not to.
The three kinds of worker
Web Workers (dedicated workers) are the workhorse. Start one, send it data, let it do CPU-bound work, get results back. Each one is its own JavaScript context with its own event loop, its own memory heap, its own microtask queue, and no access to the DOM or window. Communication is through postMessage.
Service Workers are different in kind, not just degree. They're a network proxy that runs between your app and the network, with their own lifecycle decoupled from any particular tab. They handle offline caching, push notifications, background sync, and can intercept every fetch your app makes. Think of them less as "a worker" and more as "a programmable edge layer that happens to run in the browser."
Worklets are lightweight, highly specialized workers that expose narrow slices of the rendering pipeline: Paint Worklets for custom CSS painting, Audio Worklets for low-latency audio processing, Animation Worklets for compositor-thread animation. When you need them you know. Until then, file them under "things the platform can do."
The rest of this post is about dedicated workers, the ones you'll reach for most often.
The actual mental model
A worker is not a thread in your process. It's more like a sibling process that shares nothing, and that has two consequences worth sitting with.
Workers can't touch the DOM, at all. Any UI update has to route back through a message to the main thread. That's the single-threaded DOM covenant from Post 2 being preserved: two threads writing to the DOM would reintroduce everything single-threading bought us.
Worker communication is message-passing with copy semantics by default. When you postMessage(obj), the runtime structured clones obj into the worker's heap. The data you receive on the other side is a deep copy, not a reference. This is why race conditions are impossible: there's literally no shared state to race over. It's also where most of the operational surprises live.
The hidden cost: message passing
Sending data to a worker isn't free. Structured cloning scales with the size and complexity of the payload. On a fast desktop, the rough shape is:
- Small objects: well under a millisecond
- 1 MB string: ~15 to 20ms
- 10 MB typed-array via structured clone: ~150 to 200ms
- 10 MB typed-array via transfer: sub-millisecond
That last row is the important one. For ArrayBuffer-backed data, including Uint8Array, Float32Array, ImageData, and friends, you can transfer the buffer instead of cloning it:
worker.postMessage({ buffer, width, height }, [buffer]);
// `buffer` is now neutered in this thread: ownership moved, not copied.
Transfer is near-instant regardless of size. The cost: the original reference is unusable afterwards, because the underlying memory has been reparented. For large binary data, this is almost always what you want. For anything else, structured clone is what you get.
The break-even question
The default mental model, "workers make things faster," is misleading. Workers don't make computation faster; the same work on the same machine takes the same CPU time. They keep that work off the main thread, so the main thread stays responsive for the user.
For trivial work, the overhead of message-passing dwarfs the savings, and you've made things slower and more complex. For substantial work, you've preserved frame rate during the computation, which is usually what the user actually cares about.
A rough decision framework:
| Computation time | Payload size | Use a worker? |
|---|---|---|
| < 4ms | anything | No, fits in a quarter frame |
| 4 to 16ms | small | Usually no, fits in one frame |
| 4 to 16ms, animation running | small | Maybe, if the animation is critical |
| 16 to 50ms | small | Yes, you're dropping frames otherwise |
| 50 to 100ms | moderate | Yes, clearly worth the overhead |
| > 100ms | anything | Yes, definitely |
| any | > 10 MB | Use transfer, not clone |
The threshold isn't fixed. On a six-year-old phone, 4ms work becomes 40ms work and the calculus shifts. Measure on real devices.
A concrete example: image processing
Say you're applying a blur filter to a 1920×1080 image. That's about 8 MB of pixel data and, depending on the filter, 200 to 400ms of CPU work. Three options:
Main thread. Freezes the tab for the duration. Users experience this as "clicking and nothing happens for half a second," which is the outcome you're trying hardest to avoid.
Worker with clone. Messaging 8 MB via structured clone costs ~60ms each way: you've added 120ms of messaging overhead to 300ms of work, and you're still responsive during the compute. Net: slower end-to-end, but smooth.
Worker with transfer. Messaging is effectively free. Total wall-clock is ~305ms, and the UI stays responsive the whole time. Net: faster and smooth.
For this shape of problem the answer is almost always transfer, and the 120ms you save by not cloning is the whole difference between the second and third options.
Worker pools
Creating a worker isn't free either: it typically costs 5 to 15ms, including JavaScript evaluation for the worker script. For infrequent heavy operations, spin one up on demand and terminate when done. For frequent or parallel work, pool them.
A reasonable pool keeps one worker per hardware thread (navigator.hardwareConcurrency), queues tasks when all workers are busy, and hands the next waiting task to whichever worker finishes first. The pattern is boring and well-understood; the key insight is that the pool amortizes startup cost across many jobs, and bounds your worker count to the number of cores actually available.
class WorkerPool {
constructor(script, size = navigator.hardwareConcurrency || 4) {
this.workers = Array.from({ length: size }, () => new Worker(script));
this.idle = [...this.workers];
this.queue = [];
for (const w of this.workers) w.onmessage = e => this.#done(w, e);
}
run(data, transfers) {
return new Promise(resolve => {
const task = { data, transfers, resolve };
const w = this.idle.pop();
if (w) this.#assign(w, task);
else this.queue.push(task);
});
}
#assign(w, task) { w._resolve = task.resolve; w.postMessage(task.data, task.transfers); }
#done(w, e) {
w._resolve(e.data);
const next = this.queue.shift();
if (next) this.#assign(w, next);
else this.idle.push(w);
}
}
Shared memory, with caveats
There is a way to have actual shared memory across workers: SharedArrayBuffer, paired with the Atomics API for synchronization. It reintroduces everything single-threading protected you from (race conditions, memory ordering, the need for locks) but for certain kinds of work (WebAssembly threads, real-time audio/video processing, high-frequency computations where copy overhead is prohibitive) it's indispensable.
A couple of practical notes come with it. SharedArrayBuffer requires specific security headers (Cross-Origin-Opener-Policy, Cross-Origin-Embedder-Policy) because of Spectre-class side-channel vulnerabilities, and setting that up isn't trivial. The programming model is also low-level: you're working with atomic operations over a raw buffer, not JavaScript objects. Most application code shouldn't touch it directly; it shows up inside libraries and runtimes (wasm-bindgen's thread support, for instance).
If you think you need SharedArrayBuffer, double-check. Nine times out of ten, structured clone or transfer is fine, and the tenth time you probably want a WebAssembly library that handles the atomics for you.
What workers are actually for
The fits I keep coming back to, from experience:
- Cryptography. Hashing large files, deriving keys, signing payloads. CPU-bound and main-thread-hostile by nature.
- Parsing large payloads. Multi-megabyte JSON, CSV, binary formats. A worker plus a streaming parser keeps the UI fluid.
- Image and media processing. Filters, resizing, encoding. Transfer the buffer, do the work, transfer back.
- Search indexing. Building and updating client-side search indexes for instant-search experiences.
- Compilation and code transformation. Running a babel/swc/typescript compile in the background for an in-browser IDE.
- Real-time data feeds with heavy per-message logic. A service worker or dedicated worker can do the reconciliation, then hand the main thread a ready-to-render result.
Note what's missing: DOM work, UI state, layout. Workers are for compute, not presentation.
What to internalize
Workers are the edge's answer to "I need more than one core," with a deliberate message-passing isolation model borrowed directly from distributed systems. They solve a real problem, and they add real overhead. The art is knowing which side of the break-even line a given piece of work sits on, and ruthlessly applying transfer instead of clone whenever you can.
Reaching for one is really just honoring the constraint the main thread set out from the start. The UI thread stays free to keep the interface alive, and the heavy computation goes to a context that can afford to block.