Keep the UI thread free, master multithreading
JavaScript is single-threaded, but that is not a law of nature for browser applications. Web Workers bring real multithreading to the browser: CPU-intensive calculations, file processing, encryption and AI inference run in background threads while the UI thread stays responsive and processes user interactions smoothly.
Table of Contents
- 1. Why the UI thread needs to be protected
- 2. Dedicated Workers: creating, communicating, terminating
- 3. Transferable Objects: passing data without copying
- 4. Comlink: using workers like normal objects
- 5. SharedArrayBuffer and Atomics: synchronous communication
- 6. Worker thread pool: distributing work across multiple threads
- 7. Offscreen Canvas: offloading rendering from the UI thread
- 8. Shared Workers: one worker for multiple browser tabs
- 9. Comparing worker types and communication patterns
- 10. Summary
- 11. FAQ
1. Why the UI thread needs to be protected
JavaScript in the browser runs on the main thread, which is also responsible for rendering the user interface. If a JavaScript calculation takes longer than 16 ms (at 60 fps), the browser cannot render a new frame, and the result is noticeable stutter. If the calculation takes hundreds of milliseconds, the user interface freezes completely: buttons stop responding, scrolling stops, animations hang. This is not a theoretical limitation but an everyday problem in compute-heavy web applications.
Web Workers are JavaScript's official solution to this problem. They run in fully separate threads with their own heap and their own event loop, with no shared state and no race conditions from concurrent access. Communication between the main thread and a worker happens through structured cloning via postMessage. The UI thread hands off tasks to the worker, the worker processes them without blocking the UI thread, and returns the result. The outcome: the UI thread stays responsive no matter how intensive the background work is.
2. Dedicated Workers: creating, communicating, terminating
A Dedicated Worker is the simplest worker type: it is exclusively tied to a single script and lives as long as the document that created it. Creation happens with new Worker('./worker.js'), or, when using a bundler, directly from the build system with new Worker(new URL('./worker.js', import.meta.url), { type: 'module' }). The second approach is preferable for modern projects using Vite or Webpack: the bundler recognizes the worker, builds it separately and optimizes it just like the main bundle. Type 'module' also enables ES modules in the worker, meaning import instead of importScripts().
Communication runs through postMessage and onmessage handlers. On the main thread: worker.postMessage(data) and worker.onmessage = (e) => { /* e.data */ }. In the worker: self.onmessage = (e) => { /* e.data */ } and self.postMessage(result). For more robust communication, a message protocol is recommended: every message has a type field and optionally an id that correlates requests and responses. A worker is explicitly terminated with worker.terminate(). Workers that are not terminated keep running even if the calling script is reloaded, a common source of resource leaks in single-page applications.
// main.js (robust message protocol with request-response correlation)
class WorkerBridge {
#worker
#pending = new Map()
#nextId = 0
constructor(url) {
this.#worker = new Worker(new URL(url, import.meta.url), { type: 'module' })
this.#worker.addEventListener('message', ({ data }) => {
const resolver = this.#pending.get(data.id)
if (!resolver) return
this.#pending.delete(data.id)
data.error ? resolver.reject(new Error(data.error)) : resolver.resolve(data.result)
})
this.#worker.addEventListener('error', (e) => {
console.error('[Worker Error]', e.message, e.filename, e.lineno)
})
}
// Send a task and get a Promise back
call(type, payload, transfer = []) {
return new Promise((resolve, reject) => {
const id = ++this.#nextId
this.#pending.set(id, { resolve, reject })
// transfer: move ownership of ArrayBuffer without copying
this.#worker.postMessage({ id, type, payload }, transfer)
})
}
terminate() {
this.#worker.terminate()
// Reject all pending calls
this.#pending.forEach(({ reject }) => reject(new Error('Worker terminated')))
this.#pending.clear()
}
}
// worker.js (ES module worker with typed dispatch)
self.onmessage = async ({ data: { id, type, payload } }) => {
try {
const result = await handlers[type](payload)
self.postMessage({ id, result })
} catch (e) {
self.postMessage({ id, error: e.message })
}
}
const handlers = {
sort: ({ array }) => ({ sorted: [...array].sort((a, b) => a - b) }),
hash: async ({ data }) => {
const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(data))
return { hex: Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join('') }
},
}
3. Transferable Objects: passing data without copying
By default, data is copied via structured cloning between the main thread and a Web Worker. For small objects that is not a problem. For large amounts of data, for example a 10 MB ArrayBuffer holding image or audio data, copying is expensive. Transferable Objects solve this problem: the object is physically moved to the other thread without being copied. The original thread loses access to the object (it becomes "detached"), but in return the transfer happens in O(1), regardless of the data size.
Transferable types in modern browsers are: ArrayBuffer, MessagePort, OffscreenCanvas, ImageBitmap, ReadableStream, WritableStream and TransformStream. The transfer array is passed as the second argument to postMessage. A common mistake: transferring the buffer and then still trying to use it in the sending thread, which throws a DataCloneError because the buffer is already detached. The ownership principle of Transferables is similar to Rust's ownership model: after the transfer, the object belongs to the other thread.
4. Comlink: using workers like normal objects
Comlink is a library by Google that fully abstracts away the postMessage protocol. With Comlink, a worker looks from the outside like a normal JavaScript object whose methods return Promises. In the worker you define a class and expose it with Comlink.expose(). On the main thread you create a proxy with Comlink.wrap(worker). After that you can call the worker's methods as if they were local async functions: no manual postMessage handlers, no correlation IDs, no boilerplate.
The difference between raw postMessage and Comlink is significant for maintainability. Without Comlink, every worker operation needs a message type, a handler and a response correlation implemented by hand. With Comlink it is a single method call on a proxy object. Comlink also supports transferring objects via Comlink.transfer(obj, [transfer]) and callbacks in both directions. The only downside: Comlink adds roughly 2 kB to the bundle size. For production applications that lean heavily on workers, that is an excellent trade-off.
// compute-worker.js (Comlink-exposed class in a module worker)
import * as Comlink from 'comlink'
class ImageProcessor {
// Process a large Float32Array image (result transferred back)
async applyConvolution(imageData, kernel) {
const { width, height, data } = imageData
const output = new Float32Array(data.length)
const kSize = Math.sqrt(kernel.length)
const half = Math.floor(kSize / 2)
// Convolution, CPU-intensive, runs off UI thread
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
let sum = 0
for (let ky = 0; ky < kSize; ky++) {
for (let kx = 0; kx < kSize; kx++) {
const px = Math.min(Math.max(x + kx - half, 0), width - 1)
const py = Math.min(Math.max(y + ky - half, 0), height - 1)
sum += data[py * width + px] * kernel[ky * kSize + kx]
}
}
output[y * width + x] = sum
}
}
// Transfer output buffer back to main thread (no copy)
return Comlink.transfer({ width, height, data: output }, [output.buffer])
}
}
Comlink.expose(ImageProcessor)
// main.js (use the worker as a normal class)
import * as Comlink from 'comlink'
const worker = new Worker(new URL('./compute-worker.js', import.meta.url), { type: 'module' })
const RemoteImageProcessor = Comlink.wrap(worker)
// Instantiate and call, looks like a normal async class
const processor = await new RemoteImageProcessor()
const result = await processor.applyConvolution(imageData, sharpenKernel)
console.log('Processed:', result.data.length, 'pixels')
5. SharedArrayBuffer and Atomics: synchronous communication
SharedArrayBuffer is the advanced communication pattern for Web Workers: instead of copying or transferring data, the main thread and a worker share the same memory region. Both can read and write at the same time. That sounds like a classic race condition problem, and it would be, without Atomics. The Atomics API provides atomic operations: Atomics.add(), Atomics.compareExchange(), Atomics.wait() and Atomics.notify(). These operations are indivisible: no other thread can interfere mid-operation.
The most practical Atomics pattern is Atomics.wait() and Atomics.notify(): a worker thread waits on a SharedArrayBuffer slot, the main thread writes data into the buffer and then calls Atomics.notify() to wake the worker up. This enables semaphores, locks and producer-consumer queues between threads. SharedArrayBuffer requires cross-origin isolation: the page must send the HTTP headers Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp. This is a security requirement introduced after the Spectre attack in 2018.
6. Worker thread pool: distributing work across multiple threads
For applications that process many independent tasks, a Worker Thread Pool is the right architecture. Instead of creating a new worker for every task (which has startup overhead) and terminating it immediately (which wastes resources), a pool keeps a fixed number of worker threads ready. Incoming tasks are distributed to available workers. A pool with navigator.hardwareConcurrency threads matches the number of physical CPU cores on the device, which is the optimal starting point for CPU-bound work.
A robust Worker Pool needs a queue for tasks that arrive while all workers are busy, and a mechanism to mark finished workers as available again. The basic principle is simple: an array of worker instances, an array of pending tasks, and when a task completes, the worker is immediately assigned the next task from the queue. For production-grade implementations there are libraries like workerpool and threads.js, which offer worker pools with a full Promise API and TypeScript support.
// Minimal but robust Worker Thread Pool implementation
class WorkerPool {
#workers = []
#queue = []
#busy = new Set()
constructor(workerUrl, size = navigator.hardwareConcurrency ?? 4) {
for (let i = 0; i < size; i++) {
const worker = new Worker(new URL(workerUrl, import.meta.url), { type: 'module' })
this.#workers.push(worker)
}
}
// Submit a task, returns a Promise with the result
exec(type, payload, transfer = []) {
return new Promise((resolve, reject) => {
const task = { type, payload, transfer, resolve, reject }
const available = this.#workers.find(w => !this.#busy.has(w))
if (available) {
this.#dispatch(available, task)
} else {
this.#queue.push(task)
}
})
}
#dispatch(worker, { type, payload, transfer, resolve, reject }) {
this.#busy.add(worker)
const onMessage = ({ data }) => {
cleanup()
data.error ? reject(new Error(data.error)) : resolve(data.result)
this.#busy.delete(worker)
if (this.#queue.length > 0) {
this.#dispatch(worker, this.#queue.shift())
}
}
const onError = (e) => {
cleanup()
reject(new Error(e.message))
this.#busy.delete(worker)
}
const cleanup = () => {
worker.removeEventListener('message', onMessage)
worker.removeEventListener('error', onError)
}
worker.addEventListener('message', onMessage, { once: true })
worker.addEventListener('error', onError, { once: true })
worker.postMessage({ type, payload }, transfer)
}
terminate() {
this.#workers.forEach(w => w.terminate())
this.#queue.forEach(({ reject }) => reject(new Error('Pool terminated')))
this.#queue.length = 0
}
}
// Usage: process 1000 images concurrently across all CPU cores
const pool = new WorkerPool('./image-worker.js')
const results = await Promise.all(
images.map(img => pool.exec('resize', { data: img.buffer, width: 800 }, [img.buffer]))
)
7. Offscreen Canvas: offloading rendering from the UI thread
Offscreen Canvas is an extension of the Canvas API that makes it possible to perform canvas rendering entirely inside a Web Worker. Normally, canvas rendering runs on the UI thread and competes with event handling and JavaScript execution for render time. With canvas.transferControlToOffscreen(), control over a canvas element is transferred to a worker. The worker receives an OffscreenCanvas object and can draw on it exactly like a regular canvas, using getContext('2d') for 2D graphics or getContext('webgl2') for WebGL rendering.
The main advantage of Offscreen Canvas: complex game rendering, a data visualization or real-time graphics processing no longer blocks the UI thread. Even if rendering takes 50 ms per frame, the rest of the interface stays responsive. For WebGL applications this is especially valuable: shader compilation, texture uploads and complex render passes can all happen on the worker thread. The browser takes care of transferring the rendered image to the UI thread and displaying it on screen in sync, transparently for the developer.
8. Shared Workers: one worker for multiple browser tabs
A Shared Worker is a Web Worker that can be used simultaneously by multiple browsing contexts: several tabs, frames or windows of the same origin can share the same Shared Worker. This is the right approach for tasks that need to be coordinated across tab boundaries: WebSocket connections (one connection for all tabs instead of one per tab), shared database operations, or state shared between tabs. A Shared Worker receives connections through the connect event and communicates with each context over its own MessagePort.
The limitation of Shared Workers: they are only terminated once all connected contexts have been closed. That is the desired behavior for long-lived connections, but it can lead to resource leaks if workers are not correctly disconnected from their contexts. Firefox fully supports Shared Workers, and so does Chrome. Safari had long-standing problems with them but has supported Shared Workers again since Safari 16. For simple tab coordination without the overhead of a Shared Worker, the BroadcastChannel API is also a lightweight alternative: no worker, just a pub/sub mechanism between tabs of the same origin.
| Worker Type | Scope | Communication | Typical Use |
|---|---|---|---|
| Dedicated Worker | One context | postMessage / Comlink | CPU-intensive single tasks |
| Shared Worker | Multiple tabs/frames | MessagePort per context | Shared WebSocket connection |
| Service Worker | Origin-wide | fetch/push/sync events | Offline caching, push notifications |
| Worker Pool | One context | postMessage / queue | Parallel processing of many tasks |
| Audio Worklet | Audio thread | AudioWorkletNode / SAB | Real-time audio processing |
9. Comparing worker types and communication patterns
Choosing the right communication pattern for Web Workers has a significant impact on both ergonomics and performance. Raw postMessage is the foundation: maximum control, maximum boilerplate. Comlink abstracts postMessage into Promise-based method calls, recommended for most applications. SharedArrayBuffer with Atomics is the high-performance pattern for situations where millions of data points need to be exchanged between threads and copy overhead must be avoided. Transferable Objects are the middle ground: zero-copy with asynchronous handoff.
For the decision: first check whether the problem really requires a worker. Not every long computation needs a worker; sometimes it is enough to break the work into microtasks with scheduler.yield() (new in Chrome 115) or through manual chunking with setTimeout(0). Web Workers make sense when the task is genuinely CPU-bound, when it needs its own runtime isolation, or when multiple tasks need to run in parallel. For I/O-bound tasks (API calls, file reads), a worker brings no advantage; async/await is the right choice there.
10. Summary
Web Workers are the most important performance tool for compute-intensive browser applications. Dedicated Workers for individual CPU-bound tasks, Worker Pools for parallel batch processing, Shared Workers for cross-tab coordination and Offscreen Canvas for rendering without touching the UI thread: each type has its own area of use. Communication patterns scale from simple (Comlink for most cases) to highly optimized (SharedArrayBuffer for maximum throughput).
The practical recommendation: start with Comlink, because it reduces boilerplate to a minimum and enables productive code right away. As performance requirements grow, use Transferable Objects for large data buffers. Reach for SharedArrayBuffer and Atomics only when truly synchronous coordination between threads is needed, and make sure cross-origin isolation is in place. Always explicitly terminate workers at the end of their lifecycle with terminate() to free up resources.
Web Workers in JavaScript: The Essentials at a Glance
Prefer Comlink
Comlink.expose() in the worker, Comlink.wrap() on the main thread. Worker methods become async functions. No boilerplate, no manual IDs.
Transferable Objects
Pass an ArrayBuffer as the second argument: postMessage(data, [buffer]). Zero-copy: after the transfer the sender can no longer access it.
Worker Pool for batches
navigator.hardwareConcurrency threads for optimal CPU usage. Queue for waiting tasks, assign each worker its next task the moment it finishes.
Terminate workers
Call worker.terminate() after use. Workers that aren't terminated keep running even after page navigation, a resource leak in SPAs.
Mironsoft
JavaScript performance, Web Workers and browser architecture
Need to fix UI thread problems in your web app?
We analyze your performance issues with the Chrome performance profiler, identify long tasks on the UI thread, and migrate compute-intensive operations into Web Workers, using Comlink, Transferables and Worker Pools.
Performance Audit
Long-task analysis, main thread load, and identification of worker candidates
Worker Migration
Moving compute-intensive operations into worker threads with Comlink and Transferables
Worker Pool Design
Architecture and implementation of a robust worker thread pool for batch processing