Web Workers and Multithreading in the Browser
Move heavy computation off the main thread: dedicated Workers, SharedWorker, service workers, Comlink for ergonomic worker APIs, and patterns for CPU-intensive tasks.
What you'll learn
- ✓How Dedicated Workers run JavaScript on a separate thread
- ✓How to transfer data efficiently with structured clone and transferables
- ✓How SharedWorker shares state between multiple tabs
- ✓How Comlink makes worker communication feel like normal function calls
- ✓Patterns for image processing, search, and data parsing in workers
Prerequisites
- •JavaScript async/await
- •Basic understanding of the event loop
The Problem: The Main Thread Is Busy
JavaScript is single-threaded. Every click handler, DOM update, layout calculation, and garbage collection cycle competes for the same thread. When a heavy computation takes 500ms, the page freezes for 500ms. Users notice. Core Web Vitals suffer.
Web Workers let you run JavaScript on a separate thread. The main thread stays responsive while the worker crunches numbers, parses data, or processes images in the background.
Main Thread: [UI Events] [DOM Updates] [Paint] [Layout]
Worker Thread: [Heavy Computation] → postMessage(result) →
Main Thread: [Update UI] Dedicated Workers
A dedicated worker is a separate JavaScript file that runs on its own thread. It communicates with the main thread through postMessage.
Basic Example
// main.ts
const worker = new Worker(new URL('./worker.ts', import.meta.url), {
type: 'module',
});
worker.addEventListener('message', (event) => {
console.log('Result:', event.data);
});
worker.addEventListener('error', (event) => {
console.error('Worker error:', event.message);
});
// Send work to the worker
worker.postMessage({ type: 'factorial', n: 20 });
// worker.ts
self.addEventListener('message', (event) => {
const { type, n } = event.data;
if (type === 'factorial') {
let result = 1n;
for (let i = 2n; i <= BigInt(n); i++) {
result *= i;
}
self.postMessage({ type: 'factorial', result: result.toString() });
}
});
Typed Worker Communication
Define message types for type safety:
// worker-types.ts
export type WorkerRequest =
| { type: 'sort'; data: number[] }
| { type: 'search'; haystack: string[]; needle: string }
| { type: 'parse-csv'; csv: string };
export type WorkerResponse =
| { type: 'sort'; result: number[] }
| { type: 'search'; result: string[] }
| { type: 'parse-csv'; result: Record<string, string>[] };
// data-worker.ts
import type { WorkerRequest, WorkerResponse } from './worker-types';
self.addEventListener('message', (event: MessageEvent<WorkerRequest>) => {
const request = event.data;
switch (request.type) {
case 'sort': {
const sorted = [...request.data].sort((a, b) => a - b);
respond({ type: 'sort', result: sorted });
break;
}
case 'search': {
const lowerNeedle = request.needle.toLowerCase();
const matches = request.haystack.filter((item) =>
item.toLowerCase().includes(lowerNeedle),
);
respond({ type: 'search', result: matches });
break;
}
case 'parse-csv': {
const rows = parseCsv(request.csv);
respond({ type: 'parse-csv', result: rows });
break;
}
}
});
function respond(response: WorkerResponse) {
self.postMessage(response);
}
function parseCsv(csv: string): Record<string, string>[] {
const lines = csv.split('\n').filter((l) => l.trim());
if (lines.length < 2) return [];
const headers = lines[0].split(',').map((h) => h.trim());
return lines.slice(1).map((line) => {
const values = line.split(',').map((v) => v.trim());
return Object.fromEntries(headers.map((h, i) => [h, values[i] ?? '']));
});
}
Promise-Based Worker Wrapper
The postMessage/addEventListener pattern is awkward. Wrap it in promises:
// worker-client.ts
import type { WorkerRequest, WorkerResponse } from './worker-types';
export function createWorkerClient(worker: Worker) {
let nextId = 0;
const pending = new Map<number, { resolve: Function; reject: Function }>();
worker.addEventListener('message', (event) => {
const { id, ...data } = event.data;
const handler = pending.get(id);
if (handler) {
pending.delete(id);
handler.resolve(data);
}
});
worker.addEventListener('error', (event) => {
// Reject all pending promises
for (const [id, handler] of pending) {
handler.reject(new Error(event.message));
pending.delete(id);
}
});
return function send<T extends WorkerResponse>(
request: WorkerRequest,
): Promise<T> {
return new Promise((resolve, reject) => {
const id = nextId++;
pending.set(id, { resolve, reject });
worker.postMessage({ ...request, id });
});
};
}
// Usage
const worker = new Worker(new URL('./data-worker.ts', import.meta.url), {
type: 'module',
});
const send = createWorkerClient(worker);
const result = await send<{ type: 'sort'; result: number[] }>({
type: 'sort',
data: [3, 1, 4, 1, 5, 9],
});
console.log(result.result); // [1, 1, 3, 4, 5, 9]
Transferable Objects
By default, postMessage copies data using the structured clone algorithm. For large ArrayBuffers, this is slow. Transfer ownership instead:
// Main thread: transfer an ArrayBuffer to the worker
const buffer = new ArrayBuffer(1024 * 1024); // 1MB
console.log(buffer.byteLength); // 1048576
worker.postMessage({ type: 'process', buffer }, [buffer]);
console.log(buffer.byteLength); // 0 — ownership transferred
// Worker: transfer the result back
self.addEventListener('message', (event) => {
const { buffer } = event.data;
// Process the buffer...
const result = new ArrayBuffer(buffer.byteLength);
// ...fill result...
self.postMessage({ type: 'done', result }, [result]);
});
Transferable types: ArrayBuffer, MessagePort, ReadableStream, WritableStream, TransformStream, ImageBitmap, OffscreenCanvas.
Comlink: Workers Made Easy
Comlink by Google makes worker communication feel like calling regular async functions:
npm install comlink
// heavy-math.worker.ts
import * as Comlink from 'comlink';
const api = {
fibonacci(n: number): number {
if (n <= 1) return n;
let a = 0, b = 1;
for (let i = 2; i <= n; i++) {
[a, b] = [b, a + b];
}
return b;
},
async processImage(imageData: ImageData): Promise<ImageData> {
// Apply grayscale filter
const data = imageData.data;
for (let i = 0; i < data.length; i += 4) {
const gray = data[i] * 0.299 + data[i + 1] * 0.587 + data[i + 2] * 0.114;
data[i] = gray;
data[i + 1] = gray;
data[i + 2] = gray;
}
return imageData;
},
sortLargeArray(arr: number[]): number[] {
return arr.sort((a, b) => a - b);
},
};
Comlink.expose(api);
export type HeavyMathAPI = typeof api;
// main.ts
import * as Comlink from 'comlink';
import type { HeavyMathAPI } from './heavy-math.worker';
const worker = new Worker(
new URL('./heavy-math.worker.ts', import.meta.url),
{ type: 'module' },
);
const api = Comlink.wrap<HeavyMathAPI>(worker);
// Call worker functions as if they were local
const fib = await api.fibonacci(40);
console.log('Fibonacci(40):', fib);
const sorted = await api.sortLargeArray([3, 1, 4, 1, 5, 9, 2, 6, 5, 3]);
console.log('Sorted:', sorted);
Comlink with Callbacks
// Worker exposes a function that accepts a callback
const api = {
async processWithProgress(
items: string[],
onProgress: (percent: number) => void,
) {
for (let i = 0; i < items.length; i++) {
await heavyProcess(items[i]);
onProgress(((i + 1) / items.length) * 100);
}
return { processed: items.length };
},
};
Comlink.expose(api);
// Main thread
const result = await api.processWithProgress(
largeDataset,
Comlink.proxy((percent: number) => {
progressBar.style.width = `${percent}%`;
}),
);
SharedWorker
A SharedWorker is shared between all tabs of the same origin. Useful for maintaining a single WebSocket connection or shared cache across tabs.
// shared-worker.ts
const connections: MessagePort[] = [];
self.addEventListener('connect', (event: MessageEvent) => {
const port = event.ports[0];
connections.push(port);
port.addEventListener('message', (msg) => {
if (msg.data.type === 'broadcast') {
// Send to all connected tabs
connections.forEach((p) => {
p.postMessage({ type: 'broadcast', data: msg.data.payload });
});
}
});
port.start();
port.postMessage({ type: 'connected', tabCount: connections.length });
});
// main.ts
const worker = new SharedWorker(
new URL('./shared-worker.ts', import.meta.url),
{ type: 'module' },
);
worker.port.addEventListener('message', (event) => {
console.log('From shared worker:', event.data);
});
worker.port.start();
worker.port.postMessage({ type: 'broadcast', payload: 'Hello from tab!' });
Real-World Patterns
Off-Main-Thread Search
// search-worker.ts
import * as Comlink from 'comlink';
let index: Map<string, Set<number>> = new Map();
let documents: { id: number; title: string; body: string }[] = [];
const searchAPI = {
buildIndex(docs: typeof documents) {
documents = docs;
index.clear();
for (let i = 0; i < docs.length; i++) {
const words = `${docs[i].title} ${docs[i].body}`
.toLowerCase()
.split(/\W+/)
.filter((w) => w.length > 2);
for (const word of words) {
if (!index.has(word)) index.set(word, new Set());
index.get(word)!.add(i);
}
}
return { indexed: docs.length, terms: index.size };
},
search(query: string, limit = 20) {
const terms = query.toLowerCase().split(/\W+/).filter((w) => w.length > 2);
if (terms.length === 0) return [];
const scores = new Map<number, number>();
for (const term of terms) {
for (const [word, docIds] of index) {
if (word.startsWith(term)) {
for (const id of docIds) {
scores.set(id, (scores.get(id) ?? 0) + 1);
}
}
}
}
return Array.from(scores.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, limit)
.map(([id, score]) => ({
...documents[id],
score,
}));
},
};
Comlink.expose(searchAPI);
Off-Main-Thread Image Resizing
// image-worker.ts
self.addEventListener('message', async (event) => {
const { imageBlob, maxWidth, maxHeight } = event.data;
const bitmap = await createImageBitmap(imageBlob);
const scale = Math.min(maxWidth / bitmap.width, maxHeight / bitmap.height, 1);
const width = Math.round(bitmap.width * scale);
const height = Math.round(bitmap.height * scale);
const canvas = new OffscreenCanvas(width, height);
const ctx = canvas.getContext('2d')!;
ctx.drawImage(bitmap, 0, 0, width, height);
const resizedBlob = await canvas.convertToBlob({ type: 'image/webp', quality: 0.8 });
self.postMessage({ resizedBlob }, [await resizedBlob.arrayBuffer()]);
});
Worker Lifecycle
// Create
const worker = new Worker(new URL('./worker.ts', import.meta.url), {
type: 'module',
});
// Use
worker.postMessage(data);
// Terminate when done (frees the thread)
worker.terminate();
Workers that are not terminated keep their thread alive. For one-off tasks, terminate after receiving the result. For long-lived workers (search index, WebSocket proxy), keep them alive for the page lifetime.
Summary
Web Workers are the escape hatch from the single-threaded main thread. Dedicated workers handle heavy computation. SharedWorkers share state across tabs. Comlink eliminates the messaging boilerplate. Transferable objects avoid the cost of copying large data. The pattern is consistent: identify work that blocks the main thread (sorting, parsing, image processing, search), move it to a worker, and keep the UI responsive.
Related articles
- JavaScript JavaScript Web Workers: Run Code Off the Main Thread
Learn how to use JavaScript Web Workers to run CPU-intensive code off the main thread, keeping your UI responsive with practical examples and patterns.
- Web Advanced Browser DevTools Tips and Tricks
Go beyond console.log: Network throttling, Performance flame charts, Memory snapshots, CSS debugging, JavaScript profiling, and hidden DevTools features that save hours.
- Web The Web Animations API: CSS vs JavaScript Animations
A practical guide to the Web Animations API (WAAPI): creating animations in JavaScript, controlling playback, composing effects, performance tips, and when to use CSS vs JS.
- Web Measuring and Fixing Core Web Vitals
Hands-on techniques for measuring LCP, INP, and CLS with real code: the web-vitals library, Performance Observer, Chrome UX Report, and step-by-step fixes for each metric.