Web Workers: 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. Rendering, event handling, DOM manipulation, and your application code all compete 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:
// 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 main thread creates the worker and sends it work. The worker file runs in its own context — it receives messages, does the heavy lifting, and posts results back. Notice that the two files communicate entirely through postMessage and onmessage; they share no variables or state:
// 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. This simple pattern — create, post, listen — is the foundation for every worker use case. Workers can be created either from a separate JavaScript file on disk, or built inline for self-contained logic:
Creating a web worker
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');
This is the standard approach for larger workers. For small, self-contained logic, you can embed the worker code directly in the same file using a blob URL. The browser treats it as if it were a separate file, but there is no extra request:
From a blob (inline worker)
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. Whether you use a file or a blob, communication between the main thread and the worker relies on message passing. Understanding how data moves across that boundary is critical for performance:
Passing data between threads
Workers communicate via postMessage. Data is copied, not shared, which prevents race conditions but has performance implications for large datasets.
Sending messages
// Main thread
worker.postMessage({ type: 'process', payload: largeArray });
// Worker
self.onmessage = (event) => {
const { type, payload } = event.data;
// Handle message
};
The type field convention keeps the message format predictable, letting the main thread route different kinds of messages to different handlers. Workers can also push results back asynchronously, so the main thread needs a matching listener for the response:
Receiving responses
// Main thread
worker.onmessage = (event) => {
const result = event.data;
updateUI(result);
};
// Worker
self.postMessage({ status: 'complete', result: computedValue });
The structured message pattern works well for most use cases. But when you are passing large binary data like typed arrays or buffers, the default copy behavior can hurt performance. Transferable objects move ownership instead of copying, which is much faster for large payloads:
Transferable objects
For large data like typed arrays, use transferable objects to avoid copying:
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. These communication patterns apply broadly, but workers themselves come in two flavors. Dedicated workers are tied to a single script, while shared workers can serve multiple browsing contexts:
Worker types: dedicated vs shared
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
A dedicated worker is the simplest model and works for most cases. If you need multiple tabs or frames to share a single worker instance, use a shared worker instead:
Shared workers
Shared workers can be accessed by multiple scripts in different windows, iframes, or workers:
// 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 listener for progress and completion events, then fires off the task. The worker file handles the actual data crunching, sending periodic progress updates so the UI can show a progress bar rather than staying frozen until the job finishes:
// 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 updates are especially important — without them, the user sees a frozen interface and might assume the page is broken. Once your worker is running, you should also handle errors gracefully rather than letting them crash silently:
Error Handling
Workers can throw errors just like regular JavaScript. Handle them to prevent silent failures:
// 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 });
}
This pattern lets you handle both expected errors (try/catch in the worker) and unexpected failures (the onerror handler on the main thread). Once your computation is complete, you should also terminate the worker to free its resources:
Terminating Workers
When you are done with a worker, terminate it to free resources:
// 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. Workers can also load additional scripts at creation time, which is useful for pulling in utility libraries without duplicating them:
Importing external scripts
Workers can load additional scripts using importScripts:
// 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. This is convenient but also a reminder that workers are not a free pass. They come with real constraints around DOM access, message overhead, and debugging that are worth keeping in mind:
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.
Build around the worker boundary
The worker boundary is useful because it forces you to be clear about what data moves between threads and what stays on the main page. That clarity is a design advantage, not just a performance trick. If the worker only receives the data it truly needs, the message format stays smaller and the code is easier to maintain. It also becomes easier to see which part of the app owns a piece of state.
The main thread should usually stay in charge of the interface, while the worker handles expensive calculation or data shaping. That split keeps each side focused on what it does best. The worker can take its time with raw input, while the page keeps responding to clicks and rendering updates. This division works especially well for image processing, large list filtering, and scientific or financial calculations where a bit of delay would otherwise feel like a freeze.
Message design matters more than it first appears. A small object with a clear type field and a few explicit payload fields is easier to trace than a loose collection of values. If the worker can send progress updates, completion messages, and error states in a predictable format, the main thread can react without special cases everywhere. That makes the boundary feel like a contract rather than an ad hoc channel.
Debugging is also easier when the worker stays narrow. If a worker does too many things, it becomes hard to tell whether a problem lives in the message parser, the computation itself, or the response handler. Smaller workers are often easier to test and easier to replace. They also make it simpler to decide whether the work really belongs off the main thread in the first place.
When you decide to transfer a buffer or share state across threads, treat that choice as part of the API design. Ownership changes are powerful, but they also make the data flow less obvious at a glance. The best worker code is usually the code that makes thread boundaries feel predictable and dull, which is exactly what you want from a background system.
The same discipline helps when you revisit the code later. A worker that has one clear job, one clear message format, and one clear cleanup path is much easier to keep alive than a worker that slowly grew features over time. If you need more behavior, add it with intention instead of letting the boundary blur.
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