We have all seen it. You click a button on a web app—like a button to download multiple files as a ZIP—and the page instantly freezes. The loading spinner stops spinning, the hover effects on buttons disappear, and after a few seconds, the browser asks if you want to kill the page.
This happens because JavaScript is single-threaded. When you run a heavy computation on the main thread, you block everything else. The browser cannot paint, it cannot handle clicks, and it cannot scroll. Web Workers solve this by giving us access to true background threads.
The Main Thread Bottleneck
To understand why we need workers, we have to look at how the browser manages tasks. The main thread has a lot of responsibilities. It executes JavaScript, calculates styles, performs layout operations, and paints the pixels on the screen.
For a web app to feel smooth, the browser needs to update the screen 60 times per second. This means the browser has a budget of about 16.6 milliseconds per frame. If any single task on the main thread takes longer than that, the browser misses a frame. The user experiences this as a stutter, which is a primary cause of poor responsiveness when optimizing Core Web Vitals.
Most web applications spend their time waiting for network requests or handling user input. JavaScript is great at this because of its asynchronous event loop. But async code does not mean parallel code. Running a heavy mathematical calculation, parsing a massive JSON payload, or processing an image still blocks the main thread. The event loop cannot process the next task until the current one finishes.
Enter Web Workers
A Web Worker is a script that runs in a separate thread, completely isolated from the main thread. This means you can run complex calculations for seconds or even minutes without affecting the responsiveness of the user interface.
Because workers run in a different thread, they operate in a different environment. They do not run in the context of the window object. Instead, they run inside DedicatedWorkerGlobalScope.
This isolation comes with strict limitations:
- No DOM Access: You cannot manipulate HTML elements, read layout properties, or query selectors from a worker.
- No Window Objects: You do not have access to
window.localStorage,window.location, or thedocumentobject. - Limited APIs: You can use standard JavaScript features,
fetchfor network requests, IndexedDB for storage, WebSockets, and timers likesetTimeoutandsetInterval.
Communication between the main thread and the worker is event-driven. They send messages back and forth using the postMessage API.
Building a Basic Worker
Let's look at how to set up a basic worker. We will start with two files: main.js and worker.js.
First, the main thread script:
// main.js
const worker = new Worker('worker.js');
// Send data to the worker
worker.postMessage({ number: 40 });
// Listen for messages from the worker
worker.onmessage = (event) => {
console.log('Result from worker:', event.data);
};
// Handle errors
worker.onerror = (error) => {
console.error('Worker error:', error.message);
};Next, the worker script:
// worker.js
self.onmessage = (event) => {
const { number } = event.data;
// Perform some work
const result = fibonacci(number);
// Send the result back
self.postMessage(result);
};
function fibonacci(n) {
if (n <= 1) return n;
return fibonacci(n - 1) + fibonacci(n - 2);
}When you instantiate a worker using new Worker('worker.js'), the browser starts a new operating system thread and loads the script. The two threads communicate by passing messages. The main thread sends a number, the worker calculates the Fibonacci sequence, and it sends the result back. During the calculation, the main thread is completely free to handle user input.
Offloading a Real-World Task
Let's look at a more practical example. Imagine an application that processes large arrays of objects, like a dashboard that filters and aggregates thousands of sales transactions.
Doing this on the main thread can cause noticeable delay. Let's move the sorting and filtering logic to a worker.
Here is our worker script, which handles data processing:
// processor.worker.js
self.onmessage = (event) => {
const { data, filterQuery, sortBy } = event.data;
const filtered = data.filter(item =>
item.category.toLowerCase() === filterQuery.toLowerCase()
);
const sorted = filtered.sort((a, b) => {
if (a[sortBy] < b[sortBy]) return -1;
if (a[sortBy] > b[sortBy]) return 1;
return 0;
});
self.postMessage(sorted);
};In the main application, we can wrap this worker communication in a Promise to make it easier to use with async/await syntax:
// main.js
function processData(data, filterQuery, sortBy) {
return new Promise((resolve, reject) => {
const worker = new Worker('processor.worker.js');
worker.postMessage({ data, filterQuery, sortBy });
worker.onmessage = (event) => {
resolve(event.data);
worker.terminate(); // Clean up the worker thread
};
worker.onerror = (error) => {
reject(error);
worker.terminate();
};
});
}
// Usage
async function updateUI() {
showSpinner();
try {
const largeDataset = await fetchLargeDataset();
const processed = await processData(largeDataset, 'electronics', 'price');
renderTable(processed);
} catch (error) {
showError(error);
} finally {
hideSpinner();
}
}Notice the call to worker.terminate() in the Promise resolver. Workers use system resources. If you spin up a new worker for every task and never close them, you will leak memory. Terminating the worker kills the thread and frees up those resources.
Data Transfer Mechanics: Structured Clone vs. Transferables
When you send data to a worker using postMessage, the browser does not share the memory address of that data. Instead, it copies the data.
The browser uses the Structured Clone Algorithm to serialize the object, send it across the thread boundary, and deserialize it on the other side. This prevents race conditions where both threads try to modify the same object at the same time.
For small datasets, this cloning process is fast. But if you send a 100MB array or a large image buffer, the serialization and deserialization can take tens of milliseconds. This copy operation runs on the main thread, which defeats the purpose of using a worker.
To solve this, JavaScript offers Transferable Objects.
Transferables
When you transfer an object, you pass the actual memory address to the worker. There is no copy operation. The transfer is nearly instantaneous, regardless of the data size.
The catch is that once you transfer an object, it becomes unusable on the main thread. The main thread loses access to the memory buffer.
Only specific types of objects can be transferred, such as ArrayBuffer, MessagePort, and ImageBitmap.
Here is how you use transferables:
// main.js
const worker = new Worker('worker.js');
// Create a 32MB buffer
const buffer = new ArrayBuffer(32 * 1024 * 1024);
const view = new Int32Array(buffer);
// Fill the array with data
for (let i = 0; i < view.length; i++) {
view[i] = i;
}
// Send the buffer to the worker as a transferable object
// The second argument is an array of objects to transfer
worker.postMessage({ data: buffer }, [buffer]);
console.log(buffer.byteLength); // 0 (The buffer is now empty on the main thread)In the worker, you receive the buffer normally:
// worker.js
self.onmessage = (event) => {
const buffer = event.data.data;
const view = new Int32Array(buffer);
// Process the data in place
for (let i = 0; i < view.length; i++) {
view[i] = view[i] * 2;
}
// Transfer it back to the main thread
self.postMessage({ data: buffer }, [buffer]);
};This zero-copy mechanism is critical when working with raw binary data, like WebGL textures, audio processing buffers, or large CSV datasets parsed into binary arrays.
Shared Memory with SharedArrayBuffer
If transferring ownership is not enough, you can share memory between the main thread and workers using SharedArrayBuffer. This allows both threads to read and write to the same memory space directly.
To prevent threads from writing over each other's data at the same time, you use Atomics to manage synchronization.
// main.js
const worker = new Worker('worker.js');
const sharedBuffer = new SharedArrayBuffer(1024);
const sharedArray = new Int32Array(sharedBuffer);
worker.postMessage({ sharedBuffer });
// Write to shared memory safely
Atomics.store(sharedArray, 0, 123);Using SharedArrayBuffer requires strict security headers on your server due to hardware-level vulnerabilities like Spectre. You must serve your page with:
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corpWithout these headers, SharedArrayBuffer is disabled in modern browsers.
Modern Tooling and Vite Integration
Writing raw worker files can be clunky when using modern bundlers, especially when managing frontend bundle optimization to eliminate dead code. You often want to write workers in TypeScript, import utility functions, and bundle them with the rest of your app.
Vite supports Web Workers out of the box. If you are upgrading your build setup, you can refer to the Vite 8 migration guide to ensure a smooth transition. You can import a worker file by using the ?worker suffix or by using the standard constructor syntax.
// main.ts
import MyWorker from './worker.ts?worker';
const worker = new MyWorker();
worker.postMessage('hello');Alternatively, you can use the standard syntax, which Vite detects and bundles correctly:
// main.ts
const worker = new Worker(
new URL('./worker.ts', import.meta.url),
{ type: 'module' }
);Using { type: 'module' } allows you to use standard ES imports inside your worker script.
Comlink: Simplifying the Messaging API
The message-based API of Web Workers can lead to messy code as your application grows. You end up writing large switch statements to handle different message types.
Google Chrome Labs created a library called Comlink that wraps Web Workers in an RPC (Remote Procedure Call) interface. It makes functions inside a worker look like standard async functions on the main thread.
Here is how you use Comlink:
First, define the worker:
// worker.js
import * as Comlink from 'comlink';
const api = {
add(a, b) {
return a + b;
},
heavyCalculation(data) {
// perform work
return data.map(x => x * 2);
}
};
Comlink.expose(api);Then, use it on the main thread:
// main.js
import * as Comlink from 'comlink';
async function init() {
const worker = new Worker(new URL('./worker.js', import.meta.url), { type: 'module' });
const api = Comlink.wrap(worker);
// Call worker methods as if they were local async functions
const sum = await api.add(40, 2);
console.log(sum); // 42
const result = await api.heavyCalculation([1, 2, 3]);
console.log(result); // [2, 4, 6]
}
init();Comlink handles the underlying postMessage calls and event listeners under the hood, making your codebase clean and maintainable.
Debugging Web Workers
Debugging code running in a background thread can be tricky. Fortunately, modern browser dev tools have good support for workers.
In Chrome DevTools, workers appear in the Sources panel under a dedicated section, or in the Threads list in the debugger. You can set breakpoints inside your worker code just like you would on the main thread.
Console logs inside a worker will print to the main console, but they are usually tagged with the name of the worker thread so you can tell them apart.
If you need to inspect network requests made by a worker, make sure the "All" or "Fetch/XHR" filter is selected in the Network tab. Some browsers show worker requests with a distinct gear icon next to them.
When Not to Use Web Workers
While workers are useful, they are not a default solution for every performance issue. They come with overhead.
- Startup Cost: Creating a new worker takes time. The browser has to spin up a thread and compile the script. Do not create workers for quick tasks that take less than 10-15 milliseconds.
- Serialization Overhead: If you send large objects without using transferables, the time spent copying the data can be longer than the execution time of the task on the main thread.
- Complexity: Introducing workers means managing asynchronous state, handling errors across threads, and dealing with bundler configurations.



