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. 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:
// Main thread
const worker = new Worker('worker.js');
worker.postMessage({ data: [1, 2, 3, 4, 5] });
worker.onmessage = (event) => {
console.log('Result:', event.data);
};
// 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
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)
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.
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
};
Receiving responses
// Main thread
worker.onmessage = (event) => {
const result = event.data;
updateUI(result);
};
// Worker
self.postMessage({ status: 'complete', result: computedValue });
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.
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
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 });
}
// 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.
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 });
}
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.
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.
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.
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