Engineering Diaries · JavaScript
§
JavaScriptWeb WorkersConcurrency

Web Workers Are Not Threads: What JavaScript Developers Get Wrong About Browser Parallelism

Most JavaScript developers think Web Workers give them multithreading. They don't. This deep dive explores what Web Workers actually are, how they differ from OS threads, and why the distinction matters for building performant web applications.

01 · The Misconception

JavaScript Is Single-Threaded — Except When It Isn't

If you've been writing JavaScript for more than a week, someone has told you that 'JavaScript is single-threaded.' It's the first thing we learn, the thing we repeat at interviews, and the mental model we carry into every architectural decision. But it's a half-truth that's doing real damage.

JavaScript the language doesn't have an opinion on threading. The ECMAScript specification says nothing about threads, event loops, or concurrency models. What's single-threaded is the execution context — the main thread of a browser tab, the environment in which your DOM exists, your event handlers fire, and your React components render. That's the thing that can only do one thing at a time.

JavaScript isn't single-threaded. Your execution context is. The distinction matters more than you think.

The browser itself is wildly multithreaded. Chromium spawns dozens of threads per tab — for compositing, rasterisation, network I/O, audio processing, and garbage collection. V8's garbage collector has run on background threads since 2018. When you call fetch(), the network request isn't blocking your main thread — it's handled by the browser's networking stack on a completely separate thread, and the result is marshalled back into your execution context via the event loop.

So when we say 'JavaScript is single-threaded,' what we actually mean is: your code runs on a single thread, and that thread is also responsible for layout, painting, and responding to user input. This is the real problem. It's not that you can't do two things at once — it's that computation and rendering share a single lane, and when one blocks, the other stalls.

Key Insight

The 'single-threaded' narrative oversimplifies reality. The browser is heavily multithreaded; what's single-threaded is the JavaScript execution context that shares a thread with DOM rendering. Understanding this distinction is the first step towards making informed decisions about Web Workers.

Web Workers were introduced to address precisely this bottleneck. But the moment we started calling them 'threads,' we imported a set of assumptions from systems programming that simply don't apply — and those assumptions lead to architectural mistakes that are worse than not using Workers at all.

02 · How Workers Work

Message Passing: The Fundamental Contract

A Web Worker is a separate JavaScript execution context running on a separate OS thread. So far, so thread-like. But the similarity ends almost immediately, because of one defining constraint: Workers communicate exclusively through message passing.

Here's the most basic Web Worker setup:

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

worker.postMessage({ type: 'COMPUTE', payload: [1, 2, 3, 4, 5] });

worker.onmessage = (event) => {
  console.log('Result from worker:', event.data);
};

worker.onerror = (error) => {
  console.error('Worker error:', error.message);
};
worker.js — Inside the Worker
self.onmessage = (event) => {
  const { type, payload } = event.data;

  if (type === 'COMPUTE') {
    // Heavy computation that won't block the main thread
    const result = payload.reduce((sum, n) => {
      // Simulate expensive work
      let x = n;
      for (let i = 0; i < 1_000_000; i++) {
        x = Math.sqrt(x * x + i);
      }
      return sum + x;
    }, 0);

    self.postMessage({ type: 'RESULT', value: result });
  }
};

When you call postMessage(), the browser doesn't pass a reference to your data. It creates a copy using the structured clone algorithm. This is the same algorithm behind structuredClone(), IndexedDB storage, and history.pushState(). It can handle most JavaScript types — objects, arrays, Maps, Sets, Dates, RegExps, Blobs, even circular references — but it cannot clone functions, DOM nodes, or Error objects (the last one was only recently addressed in some browsers).

What the Structured Clone Algorithm Can and Cannot Copy

Cloneable: Plain objects, Arrays, Map, Set, Date, RegExp, Blob, File, ArrayBuffer, ImageData, and all TypedArrays.

Not cloneable: Functions, DOM nodes, property descriptors (getters/setters), prototype chains, and Symbols.

This copying has real cost. If you're sending a 50MB ArrayBuffer to a Worker via postMessage(), the browser will allocate 50MB of new memory and copy every byte. For a feature that's supposed to improve performance, this can be devastatingly counterproductive if you're not careful. We'll address how to avoid this with transferable objects later — but first, let's understand why this architecture exists at all.

03 · Workers vs. Threads

The Shared-Nothing Architecture (And Why It's Intentional)

In traditional multithreaded programming — C++, Java, Rust — threads share the same memory space. Thread A can read a variable that Thread B just wrote. This is both the power and the curse of threads: power because communication is essentially free (it's just memory access), and a curse because coordinating access to shared state is one of the hardest problems in computer science.

70%
of concurrency bugs in C++ trace to data races
(Microsoft Research, 2019)
0
shared mutable state between Worker and main thread by default

Web Workers deliberately chose the opposite model. Each Worker gets its own heap, its own global scope (self instead of window), and absolutely no access to the DOM. You cannot reach into a Worker's memory from the main thread. You cannot reach into the main thread's memory from a Worker. All communication happens through serialised messages that cross the thread boundary via the browser's internal messaging infrastructure.

This is not a limitation — it's a design decision. The Web was never going to expose raw shared memory to JavaScript developers. The entire security model of the browser depends on isolation: between tabs, between origins, between iframes. Shared memory between JavaScript contexts would have been a security nightmare and a debugging catastrophe for the average web developer.

Web Workers aren't a watered-down version of threads. They're a fundamentally different concurrency model — closer to Erlang's actor model than to POSIX pthreads.

Think of it this way: in the actor model, each actor is an isolated process with its own state, and actors communicate exclusively through messages. This is precisely how Web Workers behave. No locks, no mutexes, no semaphores, no condition variables. No deadlocks. No data races (with one exception we'll cover shortly). The tradeoff is that communication has overhead — serialisation, copying, deserialisation — and that limits the kinds of problems where Workers are genuinely useful.

Understanding this distinction isn't academic. It directly affects how you architect your Worker-based code. If you structure your application as though Workers are threads — expecting low-latency, high-frequency communication with shared state — you'll build something slower than the single-threaded version. If you treat them as isolated actors that handle coarse-grained work, they can be transformative.

04 · The Exception

SharedArrayBuffer, Atomics, and the Road to Real Shared Memory

Just when you've internalised the 'no shared memory' rule, JavaScript goes and adds shared memory. SharedArrayBuffer (SAB) is a special kind of ArrayBuffer that can be accessed from multiple threads simultaneously. Unlike regular objects sent via postMessage(), a SharedArrayBuffer isn't cloned — the same backing memory is visible to both the main thread and the Worker.

Shared Memory Between Main Thread and Worker
// main.js
const sharedBuffer = new SharedArrayBuffer(1024); // 1KB shared memory
const sharedArray = new Int32Array(sharedBuffer);

sharedArray[0] = 42;

const worker = new Worker('worker.js');
worker.postMessage(sharedBuffer); // Not cloned — shared!

setTimeout(() => {
  console.log('Main thread reads:', sharedArray[0]);
  // May read 42 or 100, depending on timing
}, 1000);
worker.js — Mutating Shared Memory
self.onmessage = (event) => {
  const sharedArray = new Int32Array(event.data);

  // Both threads can now read/write the same memory
  sharedArray[0] = 100;
};

But here's the catch: the moment you have two threads writing to the same memory, you need synchronisation primitives. That's where Atomics comes in — JavaScript's answer to mutexes and memory barriers.

Using Atomics for Thread-Safe Operations
// worker.js — Atomic increment (no data race)
self.onmessage = (event) => {
  const sharedArray = new Int32Array(event.data);

  // Atomically increment index 0 by 1
  Atomics.add(sharedArray, 0, 1);

  // Wait until index 1 becomes non-zero (blocking wait)
  Atomics.wait(sharedArray, 1, 0);

  // Compare-and-swap: only set if current value matches expected
  Atomics.compareExchange(sharedArray, 0, 100, 200);
};

Atomics provides operations like add, sub, and, or, xor, load, store, compareExchange, wait, and notify. These are low-level primitives — the same kind of operations that systems programmers use to build lock-free data structures.

Security Note

SharedArrayBuffer was disabled across all major browsers in January 2018 after the Spectre CPU vulnerability was disclosed. It was re-enabled in 2020, but only for pages served with the correct cross-origin isolation headers: Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp. If you're not setting these headers, SharedArrayBuffer will throw.

The honest truth is that most web developers will never need SharedArrayBuffer. It exists primarily for compilation targets like WebAssembly (Emscripten uses it to emulate pthreads), game engines, and specialised high-performance libraries. If you find yourself reaching for Atomics.wait() in a typical web application, you've almost certainly taken a wrong turn. The message-passing model is simpler, safer, and sufficient for the vast majority of use cases.

05 · Transferable Objects

Zero-Copy Data Transfer (The Performance Trick Nobody Uses)

Remember the 50MB ArrayBuffer copying problem from earlier? Transferable objects solve it elegantly. Instead of copying data, you can transfer ownership of certain objects from one context to another. The memory doesn't move; only the reference does. After transfer, the original context can no longer access the data — it's been 'neutered' (yes, that's the actual spec terminology).

Transferring an ArrayBuffer — Zero Copy
// main.js
const largeBuffer = new ArrayBuffer(50 * 1024 * 1024); // 50MB
const view = new Uint8Array(largeBuffer);

// Fill with data
for (let i = 0; i < view.length; i++) {
  view[i] = i % 256;
}

console.log('Before transfer:', largeBuffer.byteLength); // 52428800

// Transfer ownership — near-instant, regardless of size
worker.postMessage(largeBuffer, [largeBuffer]);

console.log('After transfer:', largeBuffer.byteLength); // 0 (neutered!)

The second argument to postMessage() is the transfer list — an array of objects whose ownership should be transferred rather than cloned. The transfer itself is O(1): the browser simply reassigns the memory pointer from one context to the other.

Transferable Object Types

ArrayBuffer, MessagePort, ReadableStream, WritableStream, TransformStream, ImageBitmap, OffscreenCanvas, and VideoFrame can all be transferred. Regular objects, arrays, and TypedArrays (without their underlying buffer) cannot.

A common pattern is to transfer data to a Worker for processing, then transfer the result back:

Round-Trip Transfer Pattern
// main.js
const imageData = new ArrayBuffer(width * height * 4);
worker.postMessage({ type: 'PROCESS', buffer: imageData }, [imageData]);

worker.onmessage = (event) => {
  // Worker transferred the processed buffer back
  const processedBuffer = event.data.buffer;
  const pixels = new Uint8ClampedArray(processedBuffer);
  ctx.putImageData(new ImageData(pixels, width, height), 0, 0);
};

// worker.js
self.onmessage = (event) => {
  const { buffer } = event.data;
  const pixels = new Uint8ClampedArray(buffer);

  // Apply a greyscale filter
  for (let i = 0; i < pixels.length; i += 4) {
    const avg = (pixels[i] + pixels[i + 1] + pixels[i + 2]) / 3;
    pixels[i] = pixels[i + 1] = pixels[i + 2] = avg;
  }

  // Transfer the processed buffer back
  self.postMessage({ type: 'DONE', buffer: buffer }, [buffer]);
};

This is the single most underused feature of the Web Worker API. I've reviewed codebases where teams were cloning megabytes of data per frame — creating garbage collection pressure, memory spikes, and exactly the kind of jank they were trying to eliminate — all because they didn't know about the transfer list parameter. If you're moving binary data between contexts, transfers should be your default strategy.

06 · Practical Use Cases

Where Web Workers Actually Shine (And Where They Don't)

Web Workers are not a general-purpose performance tool. They're a scalpel for a specific problem: moving expensive computation off the main thread so the UI stays responsive. Let's look at where they genuinely help.

1. Image and Video Processing

Applying filters, resizing, format conversion — these are CPU-bound operations that can easily block the main thread for hundreds of milliseconds. An image processing pipeline in a Worker keeps the UI buttery smooth even while crunching through megapixels.

2. Data Parsing and Transformation

Parsing a 5MB CSV file or a complex JSON dataset on the main thread will freeze the UI. Workers handle this beautifully — parse in the background, stream results back to the main thread.

CSV Parsing in a Worker
// csv-worker.js
self.onmessage = (event) => {
  const csvText = event.data;
  const rows = [];
  const lines = csvText.split('\n');
  const headers = lines[0].split(',').map(h => h.trim());

  for (let i = 1; i < lines.length; i++) {
    if (!lines[i].trim()) continue;
    const values = lines[i].split(',');
    const row = {};
    headers.forEach((header, idx) => {
      row[header] = values[idx]?.trim();
    });
    rows.push(row);

    // Stream progress updates every 10,000 rows
    if (i % 10_000 === 0) {
      self.postMessage({ type: 'PROGRESS', count: i, total: lines.length });
    }
  }

  self.postMessage({ type: 'COMPLETE', data: rows });
};

3. Cryptographic Operations

Hashing, encryption, key derivation — these are inherently expensive. Libraries like bcrypt or argon2 compiled to WebAssembly run perfectly inside Workers without stalling the UI.

4. Search and Indexing

Building a client-side search index (think Lunr.js or FlexSearch) over thousands of documents is a perfect Worker task. Build the index in the background, then either transfer it back or keep the Worker alive as a dedicated search service.

5. Off-Main-Thread Architecture

This is the big one. Projects like Google's Squoosh and frameworks like Comlink advocate moving all non-UI logic into Workers — API calls, state management, business logic — leaving the main thread exclusively for rendering and input handling.

When NOT to Use Workers

Don't use Workers for: quick calculations (the postMessage overhead will exceed the computation time), anything requiring DOM access, tasks that need synchronous results, or as a substitute for proper algorithmic optimisation. If your code is slow because of an O(n²) loop, moving it to a Worker just makes it slow somewhere else.

07 · Ergonomics

Comlink, Worker Pools, and Making the API Less Painful

Let's be honest: the raw Web Worker API is clunky. Managing message types, building request-response patterns on top of postMessage, handling errors — it all feels like writing your own RPC framework from scratch. Fortunately, the community has built excellent abstractions.

Comlink by Surma (formerly of Google Chrome team) is the standout library. It uses Proxy objects and the MessageChannel API to make calling Worker functions feel like calling local async functions:

Comlink — RPC-Style Worker Communication
// worker.js
import * as Comlink from 'comlink';

const api = {
  async processImage(imageData) {
    // Heavy computation here
    const result = applyFilters(imageData);
    return result;
  },

  fibonacci(n) {
    if (n <= 1) return n;
    return this.fibonacci(n - 1) + this.fibonacci(n - 2);
  }
};

Comlink.expose(api);

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

const worker = new Worker('worker.js');
const api = Comlink.wrap(worker);

// Looks like a normal async function call!
const result = await api.fibonacci(40);
console.log(result); // 102334155

Under the hood, Comlink serialises the function call and arguments, sends them via postMessage, and returns a Promise that resolves when the Worker responds. It's beautiful — you get the ergonomics of shared-memory function calls with the safety of message passing.

Worker Pools are another critical pattern. Spawning a new Worker for each task is wasteful — Worker initialisation involves creating a new thread, parsing and compiling the script, and setting up a new JavaScript context. A pool reuses Workers across tasks:

Simple Worker Pool Implementation
class WorkerPool {
  #workers = [];
  #queue = [];
  #activeCount = 0;

  constructor(workerScript, poolSize = navigator.hardwareConcurrency || 4) {
    for (let i = 0; i < poolSize; i++) {
      this.#workers.push({ worker: new Worker(workerScript), busy: false });
    }
  }

  run(data) {
    return new Promise((resolve, reject) => {
      const available = this.#workers.find(w => !w.busy);
      const task = { data, resolve, reject };

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

  #dispatch(entry, task) {
    entry.busy = true;
    entry.worker.onmessage = (event) => {
      entry.busy = false;
      task.resolve(event.data);
      if (this.#queue.length > 0) {
        this.#dispatch(entry, this.#queue.shift());
      }
    };
    entry.worker.onerror = (err) => {
      entry.busy = false;
      task.reject(err);
    };
    entry.worker.postMessage(task.data);
  }

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

Use navigator.hardwareConcurrency to size your pool appropriately — there's no point spawning 16 Workers on a machine with 4 cores. In fact, over-provisioning Workers can degrade performance due to context switching overhead and memory pressure.

08 · Common Mistakes

Seven Anti-Patterns That Kill Web Worker Performance

After reviewing dozens of production codebases that use Web Workers, I keep seeing the same mistakes. Here's what to avoid.

  1. Chatty communication. Sending hundreds of small messages per second between the main thread and a Worker. Every postMessage call involves serialisation, cross-thread marshalling, and deserialisation. Batch your messages. Instead of sending one update per pixel, send the entire processed frame.
  2. Cloning large objects instead of transferring. If you're sending an ArrayBuffer via postMessage without the transfer list, you're doubling your memory usage for no reason. Always transfer binary data.
  3. Creating Workers on every function call. Worker creation is expensive — it involves thread creation and script compilation. Create Workers once at application startup, or use a pool.
  4. Ignoring the warm-up cost. A Worker isn't ready the instant you call new Worker(). The script needs to be fetched, parsed, and compiled. For latency-sensitive operations, pre-warm your Workers well before you need them.
  5. Using Workers for trivial computation. If the work takes less than ~10-20ms, the overhead of message passing will likely exceed the computation itself. Profile before reaching for Workers.
  6. Not handling errors. If a Worker throws an uncaught exception, it fires an error event on the Worker object in the main thread. If you haven't attached an onerror handler, the error vanishes silently. Always handle errors in both directions.
  7. Assuming Workers have access to the DOM or window APIs. There's no document, no window, no localStorage in a Worker context. You have self, fetch, IndexedDB, WebSocket, setTimeout, and importScripts — but not the full browser API surface. Attempting to access document inside a Worker will throw a ReferenceError, and if your bundler isn't configured correctly, you might not discover this until runtime.
Debugging Tip

Chrome DevTools shows Worker threads separately in the Sources panel. You can set breakpoints, inspect variables, and profile Worker execution just like main-thread code. In the Performance panel, Worker activity appears on separate swimlanes — use this to verify that your expensive work is actually running off the main thread.

The root cause of most of these mistakes is the same: developers treat Workers as if the communication boundary doesn't exist. In shared-memory threading, you can afford fine-grained interaction between threads. With Workers, every interaction has a cost, and your architecture needs to reflect that.

09 · Performance Measurement

Benchmarking Workers: How to Know If They're Actually Helping

The most dangerous assumption in performance optimisation is that your optimisation actually optimised something. Web Workers feel like a performance win because the main thread stays responsive, but total computation time might actually increase due to communication overhead. Here's how to measure properly.

Benchmarking Main Thread vs. Worker Execution
// benchmark.js — Fair comparison between main thread and Worker
async function benchmarkMainThread(data) {
  const start = performance.now();
  const result = heavyComputation(data);
  const duration = performance.now() - start;
  return { result, duration, blocking: true };
}

async function benchmarkWorker(data) {
  return new Promise((resolve) => {
    const worker = new Worker('compute-worker.js');
    const start = performance.now();

    worker.postMessage(data);
    worker.onmessage = (event) => {
      const duration = performance.now() - start;
      worker.terminate();
      resolve({
        result: event.data,
        duration,        // Includes serialisation + computation + deserialisation
        blocking: false  // Main thread was free during this time
      });
    };
  });
}

// Run both and compare
const mainResult = await benchmarkMainThread(testData);
const workerResult = await benchmarkWorker(testData);

console.table({
  'Main Thread': { time: mainResult.duration.toFixed(2) + 'ms', blocking: 'Yes' },
  'Web Worker':  { time: workerResult.duration.toFixed(2) + 'ms', blocking: 'No' }
});

Notice that we're measuring two different things. The main-thread version gives you total computation time. The Worker version gives you wall-clock time from dispatch to result, which includes serialisation overhead. The Worker version will almost always be slower in absolute terms — but the main thread is free during that time, which is the entire point.

The Three Metrics That Matter

1. Total Task Time: How long the computation takes from start to finish (Worker will be slower due to overhead).

2. Main Thread Blocking Time: How long the main thread is unresponsive (Worker should reduce this to near-zero).

3. Long Task Reduction: Use PerformanceObserver with entryTypes: ['longtask'] to measure whether Workers are eliminating tasks over 50ms on the main thread.

Monitoring Long Tasks
const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    console.warn(
      `Long task detected: ${entry.duration.toFixed(1)}ms`,
      entry.attribution
    );
  }
});

observer.observe({ type: 'longtask', buffered: true });

A good rule of thumb: if moving work to a Worker eliminates long tasks (>50ms) on the main thread without significantly degrading total throughput, it's a win. If total computation time doubles and the main thread wasn't noticeably blocked to begin with, you've added complexity without benefit. Profile with real-world data, on real-world devices — especially mid-range Android phones, where the performance characteristics are dramatically different from your development MacBook.

10 · The Bigger Picture

Off-Main-Thread Architecture: The Future of Web Performance

The web platform is moving steadily towards a model where the main thread does less and less. Every major performance initiative — Web Workers, OffscreenCanvas, CSS Houdini's paint worklets, the proposed Scheduler API — is designed to remove work from the main thread. This isn't accidental. It's the logical conclusion of a fundamental constraint: the main thread is the bottleneck, and the only way to make the web faster is to do less on it.

The off-main-thread (OMT) architecture pattern takes this to its logical extreme. Popularised by Surma's work at Google and embodied in projects like Comlink, the idea is simple: the main thread should only handle DOM updates and user input. Everything else — state management, data fetching, business logic, computation — lives in Workers.

The main thread's job is to paint pixels and respond to fingers. Everything else is a distraction — and distractions cause jank.

This is closer to how native platforms work. On iOS, the main (UI) thread is sacrosanct — you never do heavy work on it. On Android, blocking the main thread triggers ANR (Application Not Responding) dialogs. The web has been uniquely permissive in allowing developers to dump arbitrary computation onto the rendering thread, and we've paid for it with a decade of janky web apps.

The tooling isn't quite there yet. Module workers (using type: 'module' in the Worker constructor) are now supported across all major browsers, which means you can use import statements inside Workers. Build tools like Vite, webpack, and Parcel can bundle Worker scripts automatically. But the ecosystem still defaults to main-thread-first thinking.

Module Worker with ES Imports
// Supported in all modern browsers
const worker = new Worker('worker.js', { type: 'module' });

// worker.js — can now use standard ES imports
import { processData } from './data-utils.js';
import { compress } from './compression.js';

self.onmessage = async (event) => {
  const processed = processData(event.data);
  const compressed = await compress(processed);
  self.postMessage(compressed);
};

My prediction: within five years, 'everything on the main thread' will be viewed as the anti-pattern it already is — the same way we now view synchronous XMLHttpRequest or blocking script tags. The frameworks that win will be the ones that make off-main-thread architecture the default, not an opt-in. Until then, the best thing you can do is stop thinking of Web Workers as 'threads' and start thinking of them as what they really are: independent actors in a message-passing system, purpose-built for a web that needs to stay responsive above all else.

Parting Thought

Web Workers aren't threads, and that's not a weakness — it's a feature. The shared-nothing model eliminates entire categories of bugs that plague traditional multithreaded code. The cost is communication overhead, and the solution is architectural: design for coarse-grained parallelism, not fine-grained. Move big chunks of work off the main thread, transfer binary data instead of copying it, and keep the main thread free for what it does best — making pixels move.

· · ·
Written by Divyansh Singh. Published April 2026. All code examples have been tested and verified. Views expressed are the author's own.