Web Workers: Running Code Off the Main Thread
Web Workers let you run JavaScript in a background thread separate from the main UI thread. This is essential for keeping your application responsive when performing CPU-intensive operations like data processing, image manipulation, or complex calculations.
Without Web Workers, heavy computations block the main thread, causing the UI to freeze until the operation completes. Users experience this as laggy scrolling, unresponsive buttons, and general sluggishness.
Why web workers matter
The browser runs JavaScript on a single thread by default. Everything - rendering, event handling, DOM manipulation, and your code - competes for the same resources. When you run a computation that takes 500ms, the entire page freezes for half a second.
Web Workers solve this by providing a separate execution context. The main thread creates a worker and sends it data, then continues handling user interactions without waiting:
// Main thread
const worker = new Worker('worker.js');
worker.postMessage({ data: [1, 2, 3, 4, 5] });
worker.onmessage = (event) => {
console.log('Result:', event.data);
};
The worker runs in its own thread, processes the data, and posts the result back when it is done. The onmessage handler receives whatever the main thread sent through postMessage. Inside the handler, you run your computation and call self.postMessage to send the result back. Notice that the worker does not touch the DOM or access window; it operates in a restricted scope designed purely for computation:
// worker.js (runs in background)
self.onmessage = (event) => {
const result = heavyComputation(event.data.data);
self.postMessage(result);
};
function heavyComputation(data) {
return data.map(x => x * 2);
}
The main thread stays free to handle user interactions while the worker processes data in the background.
Creating a web worker
The simplest approach is to point the Worker constructor at a separate JavaScript file. The browser fetches that file, compiles it in a new thread, and hands control to the onmessage handler you defined inside it. This is the standard pattern for production code.
You can create a worker from a separate file or from a blob URL.
From a separate file
const worker = new Worker('compute-worker.js');
From a blob (inline worker)
When the worker logic is small or you want to avoid an extra network request, embed the worker code directly using a blob URL. You write the worker as a string, wrap it in a Blob, create an object URL from it, and pass that URL to the Worker constructor. The browser treats it like any other worker script:
const workerCode = `
self.onmessage = (e) => {
self.postMessage(e.data * 2);
};
`;
const blob = new Blob([workerCode], { type: 'application/javascript' });
const workerURL = URL.createObjectURL(blob);
const worker = new Worker(workerURL);
Inline workers are useful for small, self-contained workers without creating separate files. Once the worker is created, you communicate with it the same way regardless of how it was defined. The next section covers that communication in detail.
Passing data between threads
Workers communicate via postMessage. Data is copied, not shared, which prevents race conditions but has performance implications for large datasets. The copy happens through the structured clone algorithm, which handles most JavaScript types including objects, arrays, dates, and even Map and Set.
Sending messages
// Main thread
worker.postMessage({ type: 'process', payload: largeArray });
// Worker
self.onmessage = (event) => {
const { type, payload } = event.data;
// Handle message
};
Receiving responses
The main thread listens for results through the onmessage event handler. The worker calls self.postMessage with whatever data it produced: an object with a status field is a common pattern for distinguishing progress updates from final results. This convention makes the message flow predictable as your worker grows more complex:
// Main thread
worker.onmessage = (event) => {
const result = event.data;
updateUI(result);
};
// Worker
self.postMessage({ status: 'complete', result: computedValue });
Transferable objects
Copying data works for most cases, but large buffers are expensive to duplicate. Transferable objects let you hand ownership of a buffer to the worker without copying; the main thread loses access to the buffer, but the worker gains it instantly. This zero-copy transfer is ideal for ArrayBuffer, MessagePort, and ImageBitmap objects in performance-critical paths:
const buffer = new ArrayBuffer(1024 * 1024);
// Transfer ownership (original becomes unusable)
worker.postMessage(buffer, [buffer]);
// In worker
self.onmessage = (event) => {
const buffer = event.data; // Zero-copy transfer
};
Transferring is much faster than copying for large buffers, but the original reference becomes unusable after transfer.
Worker types: dedicated vs shared
So far we have used dedicated workers, tied to a single script. Shared workers allow multiple browsing contexts to connect to the same worker instance, which makes them the right choice for cross-tab coordination. Each context opens a connection through a MessagePort, and the worker can broadcast or target messages as needed.
Dedicated workers
A dedicated worker is only accessible from the script that created it:
const worker = new Worker('worker.js');
// Only this script can communicate with this worker
Shared workers
The syntax for shared workers differs slightly; you use the SharedWorker constructor and must explicitly start the port. Once connected, the communication pattern is the same postMessage/onmessage you already know, but now any tab that references the same worker URL shares the same instance. This is the foundation for real-time cross-tab features like shared shopping carts or synchronized audio players:
// Create on one page
const sharedWorker = new SharedWorker('shared-worker.js');
sharedWorker.port.start();
// Connect from another page
const sharedWorker2 = new SharedWorker('shared-worker.js');
sharedWorker2.port.start();
Shared workers are useful for cross-tab communication or maintaining shared state across browser contexts.
Worker scope and APIs
Inside a worker, you have access to a limited subset of JavaScript APIs.
Available APIs
- self - the worker’s global scope
- postMessage - send messages back to main thread
- importScripts - load external scripts
- setTimeout, setInterval - timing functions
- fetch - network requests
- IndexedDB - persistent storage
- Navigator - partial navigator information
- WorkerNavigator - worker-specific navigator
Not available
- DOM - workers cannot access the DOM
- window - no window object in workers
- document - no document object
- parent - no parent scope
This separation is intentional. Workers are designed for computation, not UI manipulation.
Practical example: data processing
Here is a complete example of processing a large dataset in a worker:
// main.js
const worker = new Worker('data-processor.js');
worker.onmessage = (event) => {
if (event.data.type === 'progress') {
updateProgressBar(event.data.percent);
} else if (event.data.type === 'complete') {
displayResults(event.data.result);
}
};
function startProcessing() {
const largeDataset = generateLargeDataset();
worker.postMessage({ data: largeDataset });
}
The main thread sets up a worker, sends it a dataset, and listens for two types of messages: progress updates for the UI and a final completion result. The worker processes items in a loop and reports its position every thousand iterations, so the user sees a progress bar instead of a frozen page. This two-message-type convention is worth adopting in your own workers; it keeps the message protocol self-describing without extra metadata fields:
// data-processor.js
self.onmessage = (event) => {
const { data } = event.data;
const results = [];
for (let i = 0; i < data.length; i++) {
// Process each item
results.push(processItem(data[i]));
// Report progress every 1000 items
if (i % 1000 === 0) {
self.postMessage({
type: 'progress',
percent: Math.round((i / data.length) * 100)
});
}
}
self.postMessage({ type: 'complete', result: results });
};
function processItem(item) {
// Expensive computation
return { ...item, processed: true, score: computeScore(item) };
}
This pattern keeps the UI responsive while processing thousands of items. The progress reporting is the key detail; without it, the user sees nothing until the entire job completes. Batched progress messages give you a feedback loop that makes long-running workers feel responsive even when the total computation takes several seconds.
Error Handling
Workers can throw errors just like regular JavaScript. The main thread can listen for errors through the onerror event, which provides the message, filename, and line number. Inside the worker, wrapping risky code in a try/catch and posting errors back as structured messages gives you finer control over how failures surface:
// Main thread
worker.onerror = (event) => {
console.error('Worker error:', event.message);
console.error('Filename:', event.filename);
console.error('Line:', event.lineno);
};
// Inside worker
try {
// Risky operation
} catch (error) {
self.postMessage({ type: 'error', message: error.message });
}
Terminating Workers
When you are done with a worker, terminate it to free resources. Calling worker.terminate() from the main thread stops the worker immediately: no cleanup, no final message, no close event. If you need a graceful shutdown, have the worker call self.close() instead, which lets it finish any in-progress work before exiting:
// Immediate termination (no cleanup)
worker.terminate();
// In worker: self-close
self.close();
Always terminate workers when they are no longer needed, especially in single-page applications where navigation does not automatically clean them up.
Importing external scripts
Workers can load additional scripts using importScripts(). This function accepts one or more URLs and loads them synchronously; the worker pauses until all scripts are fetched and executed. This is useful for pulling in utility libraries without bundling, but the synchronous nature means you should keep the dependency list short to avoid blocking the worker during startup:
// worker.js
importScripts('https://cdn.example.com/utils.js');
importScripts('./local-module.js');
// Now utils are available
const result = utilityFunction(data);
Scripts load synchronously, blocking worker execution until complete. Once scripts are loaded, you are working within the constraints of the worker environment. The next section covers what you can and cannot access inside a worker, a critical checklist to review before designing your worker architecture.
Worker limitations and considerations
No DOM access
Workers cannot manipulate the DOM directly. They must send messages to the main thread, which then updates the UI:
// Worker: cannot do this
document.querySelector('.output').textContent = 'Result';
// Must instead:
self.postMessage({ result: 'Result' });
// Main thread
worker.onmessage = (e) => {
document.querySelector('.output').textContent = e.data.result;
};
Message passing overhead
Every postMessage involves serialization and deserialization. For very small computations, this overhead might exceed the benefit of offloading work.
Browser support
Web Workers are supported in all modern browsers.
Debugging
Debug workers using the browser developer tools. Chrome DevTools shows workers in the Workers panel, and you can set breakpoints inside worker code.
Practical worker design
Workers are easier to maintain when each one handles a narrow job. A worker that parses CSV, compresses data, and manages UI state at the same time becomes hard to reason about. Keep the message format small, define a clear contract for input and output, and make the worker act like a pure function over messages when you can. That makes retries and testing simpler, because you can send the same payload and expect the same result.
Treat messages as boundaries
The main thread and the worker should not share hidden assumptions. Include a message type, a request id, and only the data needed for one task. If the task can transfer ownership of a buffer, do that up front so the worker does not pay copy cost later. This keeps message traffic predictable and makes it easier to debug when a response arrives out of order or a job is cancelled.
Lifecycle and Cleanup
Create workers when the job starts, not at page load unless you know they will be used. When work is done, terminate them and release any object URLs you created for inline code. If you keep a worker alive across route changes, give it a clear idle policy so it does not sit around waiting forever. Small lifecycle rules like that save memory and make the rest of the app feel calmer.
When a worker is not the answer
For short tasks, setup costs can outweigh the benefit. If the calculation finishes in a few milliseconds, keeping it on the main thread may be simpler and easier to profile. Use workers when the work is heavy, repetitive, or can run independently of direct UI updates. The best worker is the one that reduces jank without turning your codebase into a messaging maze.
Tasks that start after a click or route change usually have a natural place to run on the main thread first. If the first step is just to read a small form value or update a spinner, do that locally and hand off only the expensive work. That keeps the worker focused on the part that benefits from isolation.
Small jobs are fine
Not every background task needs its own worker. If the data is tiny or the work ends almost immediately, the extra setup can be more trouble than it is worth. Keep the worker for the part of the app that truly benefits from isolation, and let the main thread handle the light touches around it.
Next steps
- Try converting a long-running synchronous loop in an existing project to a Web Worker and measure the UI responsiveness improvement
- Experiment with shared workers by opening the same page in two tabs and synchronizing state between them
- Read the MDN Web Workers API reference for the full API surface, including
BroadcastChannelandMessageChannel
See Also
- MDN: Web Workers API - Complete reference for Web Workers
- Fetch and XHR - Making HTTP requests from the browser
- JavaScript Event Loop - Understanding the JavaScript execution model
- JavaScript Memory Management - How JavaScript manages memory