🚀AdvancedConcurrency & Streams

Web Workers & Concurrency

Run CPU-intensive tasks on background threads using Web Workers, SharedArrayBuffer, and Atomics for true parallel execution without blocking the UI.

Overview

JavaScript in the browser is single-threaded by default a long computation on the main thread freezes the UI, dropping frames and making the page feel broken. Web Workers solve this by providing a true parallel thread that runs JavaScript independently of the main thread. A Worker has no access to the DOM but can do any CPU-intensive work: image processing, cryptography, physics simulations, large data transforms, and WASM execution. Communication between the main thread and a Worker uses a message-passing model. postMessage sends a structured clone of the data (deep copy) to the other side; the receiver's onmessage handler fires with the data. For large binary data (images, audio, video, typed arrays), transferable objects allow zero-copy ownership transfer the sender loses access, and the receiver gets the original buffer without any copy. This is the difference between O(n) and O(1) data handoff. For scenarios where multiple workers must share mutable state, SharedArrayBuffer provides a raw block of memory accessible from any thread simultaneously. Atomics provides atomic read-modify-write operations on shared memory, preventing data races. Atomics.wait() and Atomics.notify() implement low-level futex-style synchronisation. This is genuinely low-level concurrent programming in JavaScript and must be used carefully incorrect use produces the same race conditions, deadlocks, and ABA problems as any other shared-memory concurrency.

Key Points

  • •Workers run in their own thread no shared state with the main thread, no DOM access
  • •postMessage sends a structured clone (deep copy); for large data use transferable objects to avoid copying
  • •Transferable objects (ArrayBuffer, ImageBitmap, MessagePort) are transferred with zero copy the sender loses access
  • •SharedArrayBuffer creates shared memory visible from multiple threads simultaneously
  • •Atomics operations are atomic read-modify-write they prevent data races on shared integer arrays
  • •Atomics.wait() blocks a worker until notified; Atomics.notify() wakes waiting workers (main thread cannot call wait())
  • •Worker threads cannot import ES modules directly in all browsers use importScripts() or module workers with {type:"module"}
  • •Service Workers are a special kind of Worker that intercepts network requests and enables offline-first apps

Examples

Basic Worker communication offloading a heavy computation

// worker.js
self.onmessage = function(event) {
  const { data } = event;

  // Simulate CPU-intensive work (e.g. summing a large array)
  let sum = 0;
  for (let i = 0; i < data.length; i++) {
    sum += data[i];
  }

  self.postMessage({ sum, length: data.length });
};

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

worker.onmessage = function(event) {
  const { sum, length } = event.data;
  console.log(`Sum of ${length} numbers: ${sum}`);
};

worker.onerror = function(err) {
  console.error('Worker error:', err.message);
};

// Send 1 million numbers to the worker
const data = new Float64Array(1_000_000).map((_, i) => i + 1);
// postMessage with transferable zero-copy transfer
worker.postMessage(data, [data.buffer]); // data.buffer is now transferred
// After transfer: data.byteLength === 0 (sender no longer owns it)

After transferring data.buffer, the original Float64Array becomes empty (byteLength 0). The worker now owns the buffer.

Worker pool distributing work across multiple threads

// WorkerPool runs multiple workers and distributes tasks via a queue
class WorkerPool {
  #workers = [];
  #queue   = [];
  #idle    = [];

  constructor(url, size = navigator.hardwareConcurrency) {
    for (let i = 0; i < size; i++) {
      const worker = new Worker(url);
      worker.onmessage = (e) => this.#onResult(worker, e.data);
      this.#workers.push(worker);
      this.#idle.push(worker);
    }
  }

  run(data, transfer = []) {
    return new Promise((resolve, reject) => {
      this.#queue.push({ data, transfer, resolve, reject });
      this.#dispatch();
    });
  }

  #dispatch() {
    while (this.#idle.length && this.#queue.length) {
      const worker = this.#idle.pop();
      const task = this.#queue.shift();
      worker._resolve = task.resolve;
      worker.postMessage(task.data, task.transfer);
    }
  }

  #onResult(worker, result) {
    worker._resolve(result);
    this.#idle.push(worker);
    this.#dispatch(); // pick up next queued task
  }
}

// Usage
const pool = new WorkerPool('heavy-worker.js', 4);
const tasks = Array.from({ length: 20 }, (_, i) => pool.run({ taskId: i }));
const results = await Promise.all(tasks);
console.log('All tasks done:', results.length); // 20

navigator.hardwareConcurrency returns the number of logical CPU cores, giving you a good default pool size.

SharedArrayBuffer and Atomics shared mutable state between threads

// shared-counter.js (worker)
self.onmessage = function({ data: { sab, iterations } }) {
  const view = new Int32Array(sab);
  for (let i = 0; i < iterations; i++) {
    Atomics.add(view, 0, 1); // atomic increment no race condition
  }
  self.postMessage('done');
};

// main.js
const sab = new SharedArrayBuffer(4); // 4 bytes = 1 int32
const view = new Int32Array(sab);

const workers = Array.from({ length: 4 }, () => new Worker('shared-counter.js'));
let doneCount = 0;

workers.forEach(w => {
  w.onmessage = () => {
    if (++doneCount === workers.length) {
      console.log('Final count:', Atomics.load(view, 0));
      // Should be 4 * 100_000 = 400_000 (no lost updates due to Atomics)
    }
  };
  w.postMessage({ sab, iterations: 100_000 });
});

// Without Atomics.add, using view[0]++ would produce race conditions
// and the final count would be less than 400_000

SharedArrayBuffer requires cross-origin isolation headers (COOP/COEP) to be enabled on the page for security reasons.

Common Mistakes

  • Transferring a buffer (postMessage(data, [data.buffer])) and then trying to read from it the sender loses access immediately after transfer
  • Calling Atomics.wait() on the main thread it is not allowed (it would block the event loop); only Worker threads can call Atomics.wait()
  • Using SharedArrayBuffer without cross-origin isolation headers browsers block it without COOP and COEP headers
  • Spawning a new Worker for every small task Worker startup is expensive; pool workers and reuse them for many tasks

Note

For browser apps, consider using Comlink (a tiny library wrapping the Worker postMessage API) which lets you call Worker methods as if they were async functions on the main thread. It handles the message ID bookkeeping and error propagation that you would otherwise write manually.

Exercises

Write JavaScript code to solve each exercise. Run it and check your output.

Loading editor…

Completed: 0 / 5 exercises

Knowledge Check

Question 1 of 4

What happens to a transferred ArrayBuffer on the sender's side after a zero-copy transfer?