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.
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.
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.
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:
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);
};
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).
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.
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.
(Microsoft Research, 2019)
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.
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.
// 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);
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.
// 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.
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.
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).
// 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.
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:
// 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.
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-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.
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.
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:
// 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:
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.
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.
- Chatty communication. Sending hundreds of small messages per second between the main thread and a Worker. Every
postMessagecall involves serialisation, cross-thread marshalling, and deserialisation. Batch your messages. Instead of sending one update per pixel, send the entire processed frame. - Cloning large objects instead of transferring. If you're sending an ArrayBuffer via
postMessagewithout the transfer list, you're doubling your memory usage for no reason. Always transfer binary data. - 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.
- 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. - 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.
- Not handling errors. If a Worker throws an uncaught exception, it fires an
errorevent on the Worker object in the main thread. If you haven't attached anonerrorhandler, the error vanishes silently. Always handle errors in both directions. - Assuming Workers have access to the DOM or window APIs. There's no
document, nowindow, nolocalStoragein a Worker context. You haveself,fetch,IndexedDB,WebSocket,setTimeout, andimportScripts— but not the full browser API surface. Attempting to accessdocumentinside a Worker will throw aReferenceError, and if your bundler isn't configured correctly, you might not discover this until runtime.
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.
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.
// 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.
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.
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.
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.
// 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.
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.
Related essays
- Frontend Reliability · Web Performance
Every Browser Retry You Add Is A Small DDoS Attack. Stop Calling It Reliability.
Retries keep a single request alive, but at production scale they can turn an outage into a traffic multiplier. The browser needs a retry policy, not a setTimeout loop.
- JavaScript · Browser Internals
setTimeout(fn, 0) Is Not Zero. In A Background Tab, Your 100ms Timer Runs Once A Minute.
The delay you pass to setTimeout is a minimum. Measured in Chromium, Firefox, WebKit and Node: the 4ms nesting clamp, hidden tabs throttled to 1 second, Chrome's once-a-minute intensive throttling after 5 minutes, a 30-day timer that fires instantly, and what to use instead.
- JavaScript · Browser Internals
JavaScript Reads "2026-10-06" As UTC And "2026-10-06T00:00" As Local Time. Most Off-By-One Date Bugs Start There.
new Date("2026-10-06") is UTC midnight, new Date("2026-10-06T00:00") is local midnight. Tested in Chromium, Firefox and WebKit across three time zones: where the day slips, why Indian teams rarely see it, "06-10-2026" becoming 10 June, DST arithmetic, and what Temporal fixes.