Skip to content

Web Workers for Parallel Computation

advanced22 min read

The Frozen UI Problem

You've written this perfectly reasonable code. A user uploads a CSV file with 500,000 rows, and you parse it:

function handleUpload(file) {
  const text = file.text();
  const rows = parseCSV(text); // 800ms of CPU work
  renderTable(rows);
}

During those 800ms, the browser can't respond to clicks, scrolling stops, animations freeze, and typing feels dead. The user thinks your app crashed. They rage-click the button. Now you're parsing the same CSV three times.

The problem isn't the algorithm. The problem is where it runs.

JavaScript's main thread handles everything: DOM updates, event handlers, layout, paint, garbage collection, and your code. When your code hogs the CPU for 800ms, everything else waits in line.

Mental Model

Think of the main thread as a single-lane road. Everything travels on it: UI updates, user events, your code. A Web Worker is a second road running in parallel. Heavy trucks (expensive computation) can take the worker road, keeping the main road clear for fast traffic (user interactions). The two roads communicate through rest stops (message passing), not direct access.

What Web Workers Actually Are

A Web Worker is a JavaScript execution environment running on a separate OS thread. It has its own event loop, its own global scope (self instead of window), and its own memory. It cannot access the DOM, document, window, or any main-thread API directly.

This is real parallelism, not concurrency. On a multi-core CPU, the worker thread runs on a different core simultaneously with the main thread.

// main.js — spawning a worker
const worker = new Worker('worker.js');

worker.postMessage({ type: 'parse', data: csvText });

worker.onmessage = (event) => {
  const rows = event.data;
  renderTable(rows);
};

// worker.js — runs on a separate thread
self.onmessage = (event) => {
  if (event.data.type === 'parse') {
    const rows = parseCSV(event.data.data);
    self.postMessage(rows);
  }
};

The main thread sends a message, continues handling UI events, and receives the result later. The 800ms parse happens on a background thread. Zero jank.

Quiz
What happens when a Web Worker tries to access document.getElementById('app')?

Worker Lifecycle

Workers aren't free. Each one spins up a new thread, parses and compiles a script, and allocates memory. Understanding the lifecycle helps you decide when to create them and when to reuse them.

The startup cost matters. Creating a worker, fetching and parsing its script, and initializing its environment takes anywhere from 5ms to 50ms+ depending on script size. For a task that takes 10ms of CPU time, spinning up a worker is a net loss.

Key Rules
  1. 1Reuse workers for repeated tasks — don't create and destroy them per operation
  2. 2Workers have no DOM access — they communicate exclusively via postMessage
  3. 3Each worker is a real OS thread with its own memory and event loop
  4. 4worker.terminate() kills the worker immediately — pending work is lost
  5. 5Worker startup has overhead (5-50ms+) — only worth it for tasks above ~50ms of CPU time

postMessage and the Structured Clone Algorithm

When you call postMessage(data), the browser doesn't send a pointer to data. It creates a deep copy using the Structured Clone Algorithm. This is safe — neither thread can corrupt the other's memory — but it has a cost.

// Main thread sends a large array
const bigArray = new Float64Array(1_000_000); // 8MB
worker.postMessage(bigArray); // Cloned! Now 16MB total in memory

The structured clone algorithm handles most JavaScript types: objects, arrays, Map, Set, Date, RegExp, ArrayBuffer, Blob, File, ImageData, even circular references. But it cannot clone functions, DOM nodes, Error objects (partially), or class instances with methods (they lose their prototype).

What Gets Cloned vs What Doesn't

// These clone fine:
worker.postMessage({
  name: 'Alice',
  scores: [95, 87, 92],
  metadata: new Map([['role', 'admin']]),
  created: new Date(),
  buffer: new ArrayBuffer(16),
});

// These throw or lose data:
worker.postMessage({
  handler: () => {},       // DataCloneError — functions can't be cloned
  element: document.body,  // DataCloneError — DOM nodes can't be cloned
  instance: new MyClass(), // Clones as plain object — methods are lost
});

For small messages (a few KB), the clone cost is negligible. For large data (megabytes of pixel data, huge JSON objects), the copy time becomes the bottleneck you were trying to avoid.

Quiz
You postMessage a 10MB ArrayBuffer to a worker. What is the total memory usage across both threads immediately after the message is received?

Transferable Objects: Move, Don't Copy

When you transfer an ArrayBuffer, you're not copying it — you're moving it. The source thread loses access, and the destination thread gets ownership. Zero-copy. Instant.

const pixels = new Uint8ClampedArray(4096 * 4096 * 4); // 64MB of RGBA data

// Without transfer: copies 64MB (slow, doubles memory)
worker.postMessage(pixels.buffer);

// With transfer: moves 64MB (instant, no extra memory)
worker.postMessage(pixels.buffer, [pixels.buffer]);

// After transfer, the source buffer is neutered:
console.log(pixels.buffer.byteLength); // 0 — gone
console.log(pixels.length);            // 0 — the view is empty

The second argument to postMessage is a list of Transferable objects. These get moved instead of cloned. After the transfer, the original reference becomes an empty, zero-length buffer. You cannot read or write to it anymore.

Transferable types include ArrayBuffer, MessagePort, ReadableStream, WritableStream, TransformStream, ImageBitmap, OffscreenCanvas, and VideoFrame.

// Real pattern: process an image in a worker
// main.js
const bitmap = await createImageBitmap(file);
const offscreen = new OffscreenCanvas(bitmap.width, bitmap.height);
const ctx = offscreen.getContext('2d');
ctx.drawImage(bitmap, 0, 0);
const imageData = ctx.getImageData(0, 0, bitmap.width, bitmap.height);

// Transfer the underlying buffer — zero copy
worker.postMessage(
  { width: bitmap.width, height: bitmap.height, data: imageData.data.buffer },
  [imageData.data.buffer]
);

Under the hood, transferring an ArrayBuffer updates the internal pointer in V8. The source ArrayBuffer's backing store pointer is set to null and its byte length to zero. The destination gets the original pointer. No memcpy happens. This is why it's O(1) regardless of buffer size — transferring 1KB and 1GB take the same time.

SharedArrayBuffer and Atomics: True Shared Memory

Transferable objects solve the copy problem, but they move data — only one thread can access it at a time. SharedArrayBuffer lets multiple threads read and write the same memory simultaneously.

This is powerful and dangerous. You're entering lock-free concurrent programming territory.

// main.js
const shared = new SharedArrayBuffer(1024); // 1KB shared between threads
const view = new Int32Array(shared);

const worker1 = new Worker('worker.js');
const worker2 = new Worker('worker.js');

// Both workers get access to the SAME memory
worker1.postMessage({ buffer: shared, id: 0 });
worker2.postMessage({ buffer: shared, id: 1 });
// worker.js
self.onmessage = (event) => {
  const { buffer, id } = event.data;
  const view = new Int32Array(buffer);

  // Both workers can read/write view[0] at the same time
  // Without synchronization, this is a data race!
  view[0] += 1; // NOT atomic — read + modify + write = race condition
};

The view[0] += 1 line looks atomic but it isn't. It's three operations: read the current value, add 1, write the result. If two threads do this simultaneously, one increment gets lost.

Atomics: Synchronization Primitives

The Atomics object provides thread-safe operations on SharedArrayBuffer:

// worker.js — safe increment
self.onmessage = (event) => {
  const view = new Int32Array(event.data.buffer);

  // Atomic add — guaranteed to be indivisible
  Atomics.add(view, 0, 1);

  // Other atomic operations:
  Atomics.load(view, 0);           // read without tearing
  Atomics.store(view, 0, 42);      // write without tearing
  Atomics.compareExchange(view, 0, 42, 99); // CAS operation
  Atomics.exchange(view, 0, 100);  // swap and return old value
};

Wait and Notify: Thread Coordination

Atomics.wait() blocks a worker thread until another thread signals it. This is how you build mutexes, semaphores, and producer-consumer patterns.

// worker.js — waits for the main thread to signal
const view = new Int32Array(event.data.buffer);

// Block this thread until view[0] is no longer 0
// (Cannot call Atomics.wait on the main thread — it would freeze the UI)
const result = Atomics.wait(view, 0, 0); // 'ok', 'not-equal', or 'timed-out'

// main.js — signals the worker
Atomics.store(view, 0, 1);
Atomics.notify(view, 0, 1); // Wake up 1 waiting thread
Warning

SharedArrayBuffer requires specific HTTP headers due to Spectre mitigations. Your server must send Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp. Without these headers, the SharedArrayBuffer constructor throws.

Quiz
Two workers each run Atomics.add(view, 0, 1) on the same SharedArrayBuffer index simultaneously. If view[0] started at 0, what is the final value?

Raw postMessage gets painful fast. You end up with message type strings, event handlers, manual serialization, and callback spaghetti. Comlink (by the Chrome team) wraps workers in a Proxy so you can call worker functions like they're local async functions.

// worker.js
import * as Comlink from 'comlink';

const api = {
  parseCSV(text) {
    return heavyParse(text); // runs on worker thread
  },

  async processImage(buffer) {
    return applyFilters(buffer);
  },
};

Comlink.expose(api);
// main.js
import * as Comlink from 'comlink';

const worker = new Worker('worker.js', { type: 'module' });
const api = Comlink.wrap(worker);

// Looks like a regular async function call!
const rows = await api.parseCSV(csvText);
const result = await api.processImage(buffer);

No postMessage. No onmessage. No message type switching. Comlink handles serialization, deserialization, and return values through MessageChannel internally.

Comlink supports Transferable objects through Comlink.transfer():

// Transfer an ArrayBuffer through Comlink
const result = await api.processImage(
  Comlink.transfer(buffer, [buffer])
);

Callbacks Across the Thread Boundary

Comlink even supports passing callbacks from the main thread to the worker using Comlink.proxy():

// main.js — pass a progress callback to the worker
await api.processLargeFile(
  fileData,
  Comlink.proxy((progress) => {
    progressBar.style.width = `${progress}%`;
  })
);

// worker.js
async processLargeFile(data, onProgress) {
  for (let i = 0; i < chunks.length; i++) {
    processChunk(chunks[i]);
    onProgress(((i + 1) / chunks.length) * 100);
  }
}

Module Workers

Classic workers use importScripts() for dependencies — a synchronous, unscoped, globally-polluting mechanism from 2009. Module workers use ES modules instead:

const worker = new Worker('worker.js', { type: 'module' });
// worker.js — now you can use import/export
import { parseCSV } from './csv-parser.js';
import { validate } from './validator.js';

self.onmessage = (event) => {
  const validated = validate(event.data);
  const rows = parseCSV(validated);
  self.postMessage(rows);
};

Module workers support import statements, top-level await, and tree-shaking. They're the modern approach and work in all major browsers. When using bundlers like Vite or webpack, you get automatic code-splitting and dependency resolution.

// Vite/webpack pattern — inline worker with bundler support
const worker = new Worker(
  new URL('./worker.js', import.meta.url),
  { type: 'module' }
);

SharedWorker: One Worker, Many Tabs

A SharedWorker is a single worker instance shared across all tabs/windows of the same origin. If your user has five tabs open, they all share one worker instead of spinning up five.

// Any tab can connect
const shared = new SharedWorker('shared-worker.js');

shared.port.onmessage = (event) => {
  console.log('Received:', event.data);
};

shared.port.postMessage({ action: 'subscribe', channel: 'updates' });
// shared-worker.js
const ports = [];

self.onconnect = (event) => {
  const port = event.ports[0];
  ports.push(port);

  port.onmessage = (event) => {
    if (event.data.action === 'broadcast') {
      ports.forEach(p => p.postMessage(event.data.payload));
    }
  };
};

SharedWorkers are useful for cross-tab state synchronization, shared WebSocket connections, and shared caches. But browser support is uneven — Safari only added support recently, and Chrome for Android doesn't support them. For cross-tab communication, BroadcastChannel is often simpler.

Real-World Use Cases

Image Processing

The most common and effective use case. Image operations are CPU-intensive, operate on ArrayBuffer data (perfect for transfer), and have no DOM dependency.

// worker.js — grayscale filter
self.onmessage = (event) => {
  const { data, width, height } = event.data;
  const pixels = new Uint8ClampedArray(data);

  for (let i = 0; i < pixels.length; i += 4) {
    const gray = pixels[i] * 0.299 + pixels[i + 1] * 0.587 + pixels[i + 2] * 0.114;
    pixels[i] = gray;     // R
    pixels[i + 1] = gray; // G
    pixels[i + 2] = gray; // B
    // pixels[i + 3] is alpha — leave it
  }

  self.postMessage(pixels.buffer, [pixels.buffer]); // transfer back
};

Search Indexing

Building a search index (like Pagefind or Lunr) involves tokenizing, stemming, and building inverted indices. All CPU-bound, all perfectly suited for a worker.

// worker.js
import { Index } from 'flexsearch';

let index;

self.onmessage = (event) => {
  if (event.data.type === 'build') {
    index = new Index({ tokenize: 'forward' });
    event.data.documents.forEach((doc, i) => {
      index.add(i, doc.content);
    });
    self.postMessage({ type: 'ready' });
  }

  if (event.data.type === 'search') {
    const results = index.search(event.data.query, { limit: 20 });
    self.postMessage({ type: 'results', data: results });
  }
};

Heavy JSON Parsing

JSON.parse() is synchronous and runs on the main thread. A 5MB JSON response blocks the thread for 50-100ms. Move it to a worker.

// worker.js
self.onmessage = (event) => {
  const parsed = JSON.parse(event.data);
  self.postMessage(parsed);
};

// main.js
const response = await fetch('/api/huge-dataset');
const text = await response.text();
worker.postMessage(text);

WASM Computation

WebAssembly modules (image codecs, crypto, physics engines) are CPU-intensive by design. Running them in a worker keeps the main thread free.

// worker.js
const wasmModule = await WebAssembly.instantiateStreaming(
  fetch('/codec.wasm')
);

self.onmessage = (event) => {
  const { inputBuffer } = event.data;
  const input = new Uint8Array(inputBuffer);

  const outputPtr = wasmModule.instance.exports.encode(input, input.length);
  const output = new Uint8Array(
    wasmModule.instance.exports.memory.buffer,
    outputPtr,
    wasmModule.instance.exports.getOutputSize()
  );

  const result = output.slice().buffer;
  self.postMessage(result, [result]);
};
Quiz
You need to apply a blur filter to a 4K image (3840x2160 RGBA). Which approach gives the best performance?

When NOT to Use Workers

Workers have overhead. Don't reach for them reflexively.

The Serialization Tax

Every postMessage clones the data (unless you transfer). If your computation takes 5ms but serializing and deserializing the input/output takes 20ms, you've made things slower.

// Bad: worker overhead exceeds computation
worker.postMessage({ items: smallArray }); // 3ms to clone
// Worker computes for 2ms
// Result cloned back: 3ms
// Total: 8ms (vs 2ms on main thread)

Tasks Too Small for Workers

If the computation takes less than ~50ms, the overhead of message passing likely outweighs the benefit. Quick calculations, simple string operations, basic array transformations — keep these on the main thread.

DOM-Dependent Operations

If the result needs to interact with the DOM at every step (reading layout, updating styles progressively), workers can't help. You'd need to constantly postMessage back and forth, which defeats the purpose.

When You Need Synchronous Results

Workers are inherently asynchronous. If your code structure requires a synchronous return value, a worker can't provide one (at least not without SharedArrayBuffer + Atomics.wait, which is complex and usually the wrong approach).

What developers doWhat they should do
Creating a new Worker for every single computation
Worker creation involves thread spawning, script fetching, parsing, and initialization — 5-50ms+ per worker. Amortize this cost by reusing workers.
Create workers at app startup and reuse them with a message protocol
Sending large objects via postMessage without transfer
Structured clone copies all data. A 100MB ArrayBuffer gets duplicated in memory and takes time to copy. Transfer moves it in O(1) with zero memory overhead.
Use Transferable objects for ArrayBuffers and other transferable types
Using SharedArrayBuffer when postMessage with transfer is sufficient
SharedArrayBuffer requires COOP/COEP headers, introduces race condition risks, and needs Atomics for synchronization. Transfer is simpler and safer for most cases.
Use SharedArrayBuffer only when multiple threads need simultaneous read/write access
Moving a 5ms computation to a worker to avoid blocking
The serialization, message passing, and deserialization overhead can easily exceed 5-10ms. For short tasks, the worker makes performance worse, not better.
Only offload tasks that take 50ms+ of CPU time to workers
Using importScripts() in workers instead of module workers
importScripts is synchronous, pollutes the global scope, and doesn't support tree-shaking. Module workers support import/export, top-level await, and integrate with bundlers.
Use new Worker(url, { type: 'module' }) for ES module support

Worker Pool Pattern

For CPU-intensive tasks that arrive frequently (image thumbnails, data processing pipelines), a worker pool distributes work across a fixed number of workers:

class WorkerPool {
  #workers;
  #queue;
  #available;

  constructor(url, size = navigator.hardwareConcurrency || 4) {
    this.#workers = Array.from({ length: size }, () => new Worker(url, { type: 'module' }));
    this.#queue = [];
    this.#available = [...this.#workers];
  }

  exec(data, transfer = []) {
    return new Promise((resolve, reject) => {
      const task = { data, transfer, resolve, reject };

      if (this.#available.length > 0) {
        this.#dispatch(this.#available.pop(), task);
      } else {
        this.#queue.push(task);
      }
    });
  }

  #dispatch(worker, task) {
    worker.onmessage = (event) => {
      task.resolve(event.data);
      this.#release(worker);
    };
    worker.onerror = (event) => {
      task.reject(new Error(event.message));
      this.#release(worker);
    };
    worker.postMessage(task.data, task.transfer);
  }

  #release(worker) {
    if (this.#queue.length > 0) {
      this.#dispatch(worker, this.#queue.shift());
    } else {
      this.#available.push(worker);
    }
  }

  terminate() {
    this.#workers.forEach(w => w.terminate());
  }
}

// Usage
const pool = new WorkerPool('/image-worker.js', 4);

const results = await Promise.all(
  images.map(img => pool.exec(img.buffer, [img.buffer]))
);

The pool size should match navigator.hardwareConcurrency (number of logical CPU cores). Creating more workers than cores leads to thread contention and context-switching overhead.

Error Handling in Workers

Worker errors don't propagate to the main thread automatically. Unhandled errors in workers silently disappear unless you listen for them.

// main.js
worker.onerror = (event) => {
  console.error(`Worker error: ${event.message}`);
  console.error(`  at ${event.filename}:${event.lineno}`);
  event.preventDefault(); // Prevent default error logging
};

worker.onmessageerror = (event) => {
  console.error('Failed to deserialize worker message');
};

For structured error handling with Comlink, errors thrown in the worker automatically reject the proxy's promise:

// worker.js
const api = {
  processData(data) {
    if (!data) throw new TypeError('Data is required');
    return compute(data);
  },
};
Comlink.expose(api);

// main.js
try {
  const result = await api.processData(null);
} catch (err) {
  // TypeError: Data is required — propagated from worker
  console.error(err.message);
}
Quiz
What happens if a worker throws an uncaught error and the main thread has no onerror handler on the worker?

Performance Checklist

Execution Trace
Measure first
Profile with DevTools Performance tab. Is the main thread actually blocked? Don't optimize based on assumptions
Chrome DevTools → Performance → look for long tasks (>50ms)
Consider alternatives
Can you chunk the work with scheduler.yield() or requestIdleCallback? Workers might be overkill
Smaller tasks might not need a separate thread
Choose the right data transfer
Small data: structured clone is fine. Large ArrayBuffers: use transfer. Multiple threads reading same data: SharedArrayBuffer
Pick the simplest approach that meets your performance target
Pool workers for repeated tasks
Create workers at startup, reuse them. Pool size = navigator.hardwareConcurrency
Amortize creation cost across many operations
Use module workers + Comlink
Module workers for ES imports. Comlink for ergonomic API. Skip raw postMessage in production code
Developer experience matters for maintainability
Test on real devices
A desktop with 16 cores and a phone with 4 cores have very different parallelism budgets
Always test on the lowest-spec target device
Quiz
You have a Next.js app that renders markdown to HTML on the client. The render takes 200ms for long documents. A teammate suggests using a Web Worker. What is the biggest obstacle?

Dedicated vs Shared vs Service Workers

FeatureDedicated WorkerSharedWorkerService Worker
ThreadsOne per new Worker() callOne shared across tabsOne per scope (origin + path)
AccessCreating page onlyAll same-origin pagesAll same-origin pages
LifecycleTied to creating pageStays alive while any tab connectsIndependent — survives tab close
Primary useHeavy computationCross-tab state sharingOffline caching, push notifications
DOM accessNoneNoneNone
CommunicationpostMessageMessagePortpostMessage + Fetch events
Key Rules
  1. 1Use Dedicated Workers for CPU-bound computation — they are the simplest and most widely supported
  2. 2Use SharedWorkers only when multiple tabs need to share a single connection or state
  3. 3Workers cannot access the DOM — always verify your libraries are DOM-free before offloading
  4. 4Transfer ArrayBuffers instead of cloning them for large binary data
  5. 5SharedArrayBuffer requires COOP and COEP headers — plan your deployment accordingly
  6. 6Comlink eliminates postMessage boilerplate — use it in production codebases
  7. 7Pool size should match navigator.hardwareConcurrency to avoid thread contention