The Main Thread Problem
JavaScript runs on a single thread. Parse a 500 MB JSON file, process images, or run cryptographic operations, and your UI freezes. Users see a blank screen, buttons don't respond, and your app feels broken. Web Workers: concepts, trade-offs and production lessons teaches us how to move heavy work off the main thread and keep applications responsive.
Web Workers are a browser API that lets you spawn background threads running JavaScript independently. They don't have access to the DOM, but they can compute, parse, and transform data without blocking user interaction. We've learned this the hard way in production—moving the right work to workers transforms app feel from sluggish to snappy.
When to Use Web Workers
Not every task belongs in a worker. The overhead of spawning a worker and messaging data means small, fast operations often run faster on the main thread. Workers shine when you have genuinely heavy lifting that would otherwise freeze the UI.
Parsing Large Files
JSON parsing is synchronous and blocking. A 50 MB JSON file takes hundreds of milliseconds on the main thread. Move it to a worker, and the UI stays responsive while parsing happens in the background.
// main.ts
const worker = new Worker('parser.js');
async function loadLargeJSON(file: File): Promise<unknown> {
return new Promise((resolve) => {
worker.postMessage({ type: 'parse', file });
worker.onmessage = (event) => {
resolve(event.data);
};
});
}
const result = await loadLargeJSON(selectedFile);// parser.js (worker)
self.onmessage = async (event) => {
const { type, file } = event.data;
if (type === 'parse') {
const text = await file.text();
const data = JSON.parse(text);
self.postMessage(data);
}
};In a real e-commerce dashboard, we moved CSV import parsing to a worker. Processing a 30,000-row export that took 1.2 seconds on the main thread now takes 1.1 seconds in a worker—the benefit isn't speed, it's that the UI stays interactive during that 1.1 seconds.
Image Processing
Resizing, filtering, or converting images requires looping over pixel data. A 4K image canvas operation can lock the main thread for seconds. Workers handle this without stuttering animations or blocking input.
// main.ts
const imageWorker = new Worker('imageProcessor.js');
function processImage(imageData: ImageData): Promise<ImageData> {
return new Promise((resolve) => {
imageWorker.postMessage({ imageData });
imageWorker.onmessage = (event) => {
resolve(event.data.imageData);
};
});
}
const canvas = document.getElementById('canvas') as HTMLCanvasElement;
const ctx = canvas.getContext('2d')!;
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const processed = await processImage(imageData);
ctx.putImageData(processed, 0, 0);// imageProcessor.js (worker)
self.onmessage = (event) => {
const { imageData } = event.data;
const data = imageData.data;
// Apply grayscale filter
for (let i = 0; i < data.length; i += 4) {
const avg = (data[i] + data[i + 1] + data[i + 2]) / 3;
data[i] = avg;
data[i + 1] = avg;
data[i + 2] = avg;
}
self.postMessage({ imageData });
};Heavy Calculations
Cryptography, compression, matrix math, or data analysis—anything computational scales well to workers. We've used them for real-time forecasting, ML inference, and encryption in travel and trading platforms.
// main.ts
const calcWorker = new Worker('calculator.js');
function runExpensiveCalculation(data: number[]): Promise<number> {
return new Promise((resolve) => {
calcWorker.postMessage({ data });
calcWorker.onmessage = (event) => {
resolve(event.data.result);
};
});
}
const numbers = Array.from({ length: 1000000 }, Math.random);
const result = await runExpensiveCalculation(numbers);// calculator.js (worker)
self.onmessage = (event) => {
const { data } = event.data;
let result = 0;
// Expensive computation
for (let i = 0; i < data.length; i++) {
result += Math.sin(data[i]) * Math.cos(data[i]);
}
self.postMessage({ result });
};Messaging: postMessage and onmessage
Workers communicate with the main thread via messages. The main thread sends data with postMessage and listens with onmessage. The worker does the same in reverse.
// main.ts — sending and receiving
const worker = new Worker('worker.js');
// Send data to worker
worker.postMessage({
type: 'process',
payload: { items: [1, 2, 3] },
id: Math.random()
});
// Listen for responses
worker.onmessage = (event) => {
const { type, result, id } = event.data;
console.log(`Worker completed task ${id}:`, result);
};
// Handle errors
worker.onerror = (error) => {
console.error('Worker error:', error.message);
};// worker.js — responding
self.onmessage = (event) => {
const { type, payload, id } = event.data;
if (type === 'process') {
const result = payload.items.map(x => x * 2);
self.postMessage({ type: 'result', result, id });
}
};
// Throw errors to main thread
self.onerror = (error) => {
console.error('Worker encountered error:', error);
};Messages are cloned by default, which means the worker gets a deep copy of your data. This is safe but adds memory overhead. For large datasets, use transferables.
Transferables: Zero-Copy Data Movement
ArrayBuffers and TypedArrays can be transferred, not copied. This moves ownership to the worker without duplicating memory—crucial when passing gigabytes of data.
// main.ts — transfer ownership
const buffer = new ArrayBuffer(1024 * 1024); // 1 MB
const view = new Uint8Array(buffer);
// Fill with data
for (let i = 0; i < view.length; i++) {
view[i] = Math.random() * 256;
}
// Transfer (not copy) to worker
worker.postMessage({ buffer }, [buffer]);
// buffer is now unusable on main thread
console.log(buffer.byteLength); // 0// worker.js — receives transferred buffer
self.onmessage = (event) => {
const { buffer } = event.data;
const view = new Uint8Array(buffer);
// Process data
let sum = 0;
for (let i = 0; i < view.length; i++) {
sum += view[i];
}
// Transfer result back
const resultBuffer = new ArrayBuffer(8);
new Float64Array(resultBuffer)[0] = sum;
self.postMessage({ result: sum }, [resultBuffer]);
};In a trading platform processing real-time market data, transferables let us send 100 MB frames from the main thread to workers without copying. The performance difference is measurable—megabytes per second matter at that scale.
Libraries: Comlink for Cleaner Code
Raw postMessage is verbose. Comlink wraps workers to expose them as async functions, making code feel synchronous and natural.
// main.ts — with Comlink
import * as Comlink from 'comlink';
const worker = Comlink.wrap<typeof import('./worker')>(
new Worker(new URL('./worker.ts', import.meta.url), { type: 'module' })
);
// Call worker methods like async functions
const result = await worker.heavyComputation([1, 2, 3]);
const processed = await worker.parseFile(file);// worker.ts
export async function heavyComputation(items: number[]): Promise<number> {
let result = 0;
for (let i = 0; i < items.length; i++) {
result += Math.sin(items[i]);
}
return result;
}
export async function parseFile(file: File): Promise<unknown> {
const text = await file.text();
return JSON.parse(text);
}
Comlink.expose({ heavyComputation, parseFile });Comlink handles threading, error handling, and even transfers automatically. The code reads like regular async/await. We've used Comlink in SaaS products for document processing and CRM data transformation—it eliminates boilerplate and makes workers feel less foreign.
Common Mistakes
- Creating too many workers. Each worker is a separate thread consuming memory and startup time. One or two persistent workers is usually enough. Use a pool pattern for batch tasks.
- Blocking the worker thread. Workers have the same single-threaded limitations as the main thread. Expensive loops still block. Break them into chunks with
setTimeout. - Assuming workers have DOM access. They don't. No document, window, or DOM APIs. Workers are isolated, which is why they're safe and performant.
- Forgetting to handle errors. Worker errors don't always bubble up. Always attach
onerrorhandlers and log carefully. - Messaging large objects carelessly. Cloning a 100 MB object defeats the purpose. Use transferables for large data, and consider splitting payloads into chunks.
- Not considering latency. Worker overhead (spawn time, message serialization) is ~1-5ms. For tasks under 10ms, the main thread is faster.
Key Takeaways
- Web Workers move heavy computation, parsing, and processing off the main thread, keeping UIs responsive during blocking operations.
- Use workers for genuinely expensive tasks: large file parsing, image processing, cryptography, and heavy calculations. Don't use them for trivial work.
- Understand the cost: message serialization, worker spawn overhead, and memory per worker. A 2ms task doesn't benefit from a worker.
- Transferables (ArrayBuffer) move data without copying. Essential for high-throughput scenarios like real-time video or market data.
- Comlink simplifies worker code by wrapping them as async functions, reducing boilerplate and making threading patterns clearer.
- Always handle errors with
onerroron both main thread and worker. Message-passing failure is silent by default. - Consider pooling: reuse persistent workers rather than creating new ones per task.






Comments
Loading comments…