Web Workers: How to Use Threads in JavaScript to Improve Performance
Hey HaWkers, JavaScript is single-threaded by design. This means heavy operations block the main thread, causing interface freezes. Web Workers solve this problem by allowing code to run in separate threads.
Let's explore how to use Web Workers to create more responsive and performant applications.
The Main Thread Problem
The JavaScript main thread is responsible for:
- Executing your code
- Processing DOM events
- Rendering visual updates
- Responding to user interactions
When you run heavy code, all these tasks get blocked:
// ❌ BAD - Blocks the main thread
function processHeavyData(data) {
// Simulation of heavy processing
const result = [];
for (let i = 0; i < 10000000; i++) {
result.push(Math.sqrt(data[i % data.length]) * Math.random());
}
return result;
}
// During this execution, UI is frozen
// Clicks don't respond, animations stop
const result = processHeavyData(myData);Symptoms of blocked main thread:
- Frozen interface
- Paused animations
- Unresponsive inputs
- Stuttering scroll
- "Page Unresponsive" in browser
How Web Workers Work
Web Workers run JavaScript in a separate thread, completely isolated from the main thread.
Communication via Messages
Workers communicate with the main thread through messages:
// main.js - Main thread
const worker = new Worker('worker.js');
// Send data to worker
worker.postMessage({ data: myData, type: 'process' });
// Receive result from worker
worker.onmessage = (event) => {
console.log('Result:', event.data);
// Update UI with result
updateInterface(event.data);
};
// Handle errors
worker.onerror = (error) => {
console.error('Worker error:', error.message);
};// worker.js - Separate thread
self.onmessage = (event) => {
const { data, type } = event.data;
if (type === 'process') {
// Heavy processing - doesn't block UI
const result = processData(data);
// Send result back
self.postMessage(result);
}
};
function processData(data) {
const result = [];
for (let i = 0; i < 10000000; i++) {
result.push(Math.sqrt(data[i % data.length]) * Math.random());
}
return result;
}
Types of Workers
Dedicated Workers
The most common type. Exclusive to a specific script:
// Create dedicated worker
const worker = new Worker('worker.js');
// Terminate worker when no longer needed
worker.terminate();Shared Workers
Shared between multiple tabs/windows of the same origin:
// main.js
const sharedWorker = new SharedWorker('shared-worker.js');
sharedWorker.port.onmessage = (event) => {
console.log('Message received:', event.data);
};
sharedWorker.port.postMessage('Hello from tab');// shared-worker.js
const connections = [];
self.onconnect = (event) => {
const port = event.ports[0];
connections.push(port);
port.onmessage = (event) => {
// Broadcast to all connections
connections.forEach(conn => {
conn.postMessage(`Broadcast: ${event.data}`);
});
};
port.start();
};Service Workers
For cache, push notifications and offline functionality (not covered in detail here).
Practical Use Cases
1. Image Processing
// main.js
const imageWorker = new Worker('image-worker.js');
async function processImage(imageFile) {
const bitmap = await createImageBitmap(imageFile);
// Use OffscreenCanvas to process in worker
const canvas = document.createElement('canvas');
canvas.width = bitmap.width;
canvas.height = bitmap.height;
const offscreen = canvas.transferControlToOffscreen();
// Transfer canvas to worker (doesn't copy, transfers)
imageWorker.postMessage(
{ canvas: offscreen, bitmap },
[offscreen, bitmap]
);
}
imageWorker.onmessage = (event) => {
if (event.data.type === 'progress') {
updateProgressBar(event.data.percentage);
} else if (event.data.type === 'complete') {
displayResult(event.data.imageData);
}
};// image-worker.js
self.onmessage = async (event) => {
const { canvas, bitmap } = event.data;
const ctx = canvas.getContext('2d');
ctx.drawImage(bitmap, 0, 0);
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
// Apply filter (example: grayscale)
const pixels = imageData.data;
for (let i = 0; i < pixels.length; i += 4) {
const avg = (pixels[i] + pixels[i + 1] + pixels[i + 2]) / 3;
pixels[i] = avg; // R
pixels[i + 1] = avg; // G
pixels[i + 2] = avg; // B
// Report progress
if (i % 100000 === 0) {
self.postMessage({
type: 'progress',
percentage: Math.round((i / pixels.length) * 100)
});
}
}
ctx.putImageData(imageData, 0, 0);
self.postMessage({
type: 'complete',
imageData: ctx.getImageData(0, 0, canvas.width, canvas.height)
});
};2. Large File Parsing
// main.js
const csvWorker = new Worker('csv-worker.js');
async function processCSV(file) {
const text = await file.text();
csvWorker.postMessage({ csv: text, options: { delimiter: ',' } });
}
csvWorker.onmessage = (event) => {
const { type, data, error } = event.data;
if (type === 'success') {
console.log(`Processed ${data.length} rows`);
renderTable(data);
} else if (type === 'error') {
console.error('Error processing CSV:', error);
}
};// csv-worker.js
self.onmessage = (event) => {
try {
const { csv, options } = event.data;
const lines = csv.split('\n');
const headers = lines[0].split(options.delimiter);
const data = [];
for (let i = 1; i < lines.length; i++) {
if (!lines[i].trim()) continue;
const values = lines[i].split(options.delimiter);
const object = {};
headers.forEach((header, index) => {
object[header.trim()] = values[index]?.trim();
});
data.push(object);
}
self.postMessage({ type: 'success', data });
} catch (error) {
self.postMessage({ type: 'error', error: error.message });
}
};
3. Complex Mathematical Calculations
// main.js
const mathWorker = new Worker('math-worker.js');
function calculateStatistics(numbers) {
return new Promise((resolve, reject) => {
mathWorker.postMessage({ numbers });
mathWorker.onmessage = (event) => {
resolve(event.data);
};
mathWorker.onerror = (error) => {
reject(error);
};
});
}
// Usage
const stats = await calculateStatistics(largeNumberArray);
console.log('Mean:', stats.mean);
console.log('Standard Deviation:', stats.standardDeviation);// math-worker.js
self.onmessage = (event) => {
const { numbers } = event.data;
// Mean
const sum = numbers.reduce((acc, n) => acc + n, 0);
const mean = sum / numbers.length;
// Variance
const sumSquares = numbers.reduce((acc, n) => {
return acc + Math.pow(n - mean, 2);
}, 0);
const variance = sumSquares / numbers.length;
// Standard Deviation
const standardDeviation = Math.sqrt(variance);
// Median
const sorted = [...numbers].sort((a, b) => a - b);
const middle = Math.floor(sorted.length / 2);
const median = sorted.length % 2
? sorted[middle]
: (sorted[middle - 1] + sorted[middle]) / 2;
self.postMessage({
mean,
median,
variance,
standardDeviation,
min: Math.min(...numbers),
max: Math.max(...numbers),
});
};
Worker with TypeScript and Bundlers
Configuration with Vite
// src/workers/data-processor.worker.ts
self.onmessage = (event: MessageEvent<{ data: number[] }>) => {
const { data } = event.data;
const result = data.map(n => n * 2);
self.postMessage(result);
};
export {}; // Required for TypeScript to treat as module// src/main.ts
// Vite supports workers natively with ?worker
import DataWorker from './workers/data-processor.worker?worker';
const worker = new DataWorker();
worker.postMessage({ data: [1, 2, 3, 4, 5] });
worker.onmessage = (event) => {
console.log('Result:', event.data); // [2, 4, 6, 8, 10]
};Configuration with Webpack
// webpack.config.js
module.exports = {
module: {
rules: [
{
test: /\.worker\.ts$/,
use: {
loader: 'worker-loader',
options: {
filename: '[name].[contenthash].worker.js',
},
},
},
],
},
};
Transferable Objects
For large data, use Transferable Objects to avoid copies:
// main.js
const buffer = new ArrayBuffer(1024 * 1024 * 100); // 100MB
// ❌ Copies the data (slow for large data)
worker.postMessage({ buffer });
// ✅ Transfers ownership (instantaneous)
worker.postMessage({ buffer }, [buffer]);
// After this, `buffer` is no longer available in main threadTransferable Types
// ArrayBuffer
const arrayBuffer = new ArrayBuffer(1024);
worker.postMessage({ data: arrayBuffer }, [arrayBuffer]);
// MessagePort
const channel = new MessageChannel();
worker.postMessage({ port: channel.port2 }, [channel.port2]);
// ImageBitmap
const bitmap = await createImageBitmap(imageBlob);
worker.postMessage({ image: bitmap }, [bitmap]);
// OffscreenCanvas
const offscreen = canvas.transferControlToOffscreen();
worker.postMessage({ canvas: offscreen }, [offscreen]);
Worker Pool
For parallel tasks, use a worker pool:
// worker-pool.js
class WorkerPool {
constructor(workerScript, poolSize = navigator.hardwareConcurrency || 4) {
this.workers = [];
this.queue = [];
this.activeWorkers = new Set();
for (let i = 0; i < poolSize; i++) {
const worker = new Worker(workerScript);
worker.id = i;
this.workers.push(worker);
}
}
execute(data) {
return new Promise((resolve, reject) => {
const task = { data, resolve, reject };
const availableWorker = this.workers.find(
w => !this.activeWorkers.has(w.id)
);
if (availableWorker) {
this.runTask(availableWorker, task);
} else {
this.queue.push(task);
}
});
}
runTask(worker, task) {
this.activeWorkers.add(worker.id);
worker.onmessage = (event) => {
task.resolve(event.data);
this.activeWorkers.delete(worker.id);
this.processQueue();
};
worker.onerror = (error) => {
task.reject(error);
this.activeWorkers.delete(worker.id);
this.processQueue();
};
worker.postMessage(task.data);
}
processQueue() {
if (this.queue.length === 0) return;
const availableWorker = this.workers.find(
w => !this.activeWorkers.has(w.id)
);
if (availableWorker) {
const task = this.queue.shift();
this.runTask(availableWorker, task);
}
}
terminate() {
this.workers.forEach(w => w.terminate());
}
}
// Usage
const pool = new WorkerPool('processor.worker.js', 4);
const results = await Promise.all([
pool.execute({ chunk: data.slice(0, 1000) }),
pool.execute({ chunk: data.slice(1000, 2000) }),
pool.execute({ chunk: data.slice(2000, 3000) }),
pool.execute({ chunk: data.slice(3000, 4000) }),
]);
Web Workers Limitations
What Is NOT Available
// worker.js
// ❌ DOM is not available
document.getElementById('something'); // Error
// ❌ window doesn't exist
window.localStorage; // Error
// ❌ alert, confirm, prompt don't exist
alert('test'); // Error
// ✅ But you can use:
fetch('/api/data'); // OK
setTimeout(() => {}, 1000); // OK
console.log('test'); // OK
crypto.randomUUID(); // OK
self.indexedDB; // OKCommon Workarounds
// If you need DOM data, extract before sending to worker
// main.js
const elementsData = Array.from(document.querySelectorAll('.item'))
.map(el => ({
text: el.textContent,
dataset: { ...el.dataset }
}));
worker.postMessage({ elements: elementsData });Conclusion
Web Workers are a powerful tool for keeping your applications responsive. Use them for:
- Heavy data processing
- Large file parsing
- Complex mathematical calculations
- Image processing
- Any operation that takes more than 50ms
Remember best practices:
- Use Transferable Objects for large data
- Implement worker pools for parallel tasks
- Handle errors appropriately
- Terminate workers when you no longer need them
If you're interested in more performance techniques, I recommend checking out the article Debugging JavaScript: Advanced Techniques with DevTools where we explore how to identify performance bottlenecks in your application.

