Back to Blog
Web WorkersPerformanceJavaScriptThreading

Web Workers for Background Processing

Offload heavy computation with Web Workers. From basic workers to shared workers to communication patterns.

B
Bootspring Team
Engineering
December 30, 2021
6 min read

Web Workers enable multi-threaded JavaScript. Here's how to use them for heavy computation without blocking the UI.

Basic Web Worker#

1// worker.js 2self.onmessage = function(event) { 3 const { data } = event; 4 5 // Heavy computation 6 const result = processData(data); 7 8 // Send result back 9 self.postMessage(result); 10}; 11 12function processData(data) { 13 // CPU-intensive work 14 let result = 0; 15 for (let i = 0; i < data.iterations; i++) { 16 result += Math.sqrt(i); 17 } 18 return result; 19} 20 21// main.js 22const worker = new Worker('worker.js'); 23 24worker.onmessage = function(event) { 25 console.log('Result:', event.data); 26}; 27 28worker.onerror = function(error) { 29 console.error('Worker error:', error); 30}; 31 32worker.postMessage({ iterations: 1000000 });

TypeScript Web Worker#

1// worker.ts 2interface WorkerMessage { 3 type: 'process' | 'cancel'; 4 payload?: any; 5} 6 7interface WorkerResponse { 8 type: 'result' | 'progress' | 'error'; 9 data: any; 10} 11 12self.onmessage = (event: MessageEvent<WorkerMessage>) => { 13 const { type, payload } = event.data; 14 15 switch (type) { 16 case 'process': 17 try { 18 const result = heavyComputation(payload); 19 self.postMessage({ type: 'result', data: result } as WorkerResponse); 20 } catch (error) { 21 self.postMessage({ 22 type: 'error', 23 data: (error as Error).message, 24 } as WorkerResponse); 25 } 26 break; 27 28 case 'cancel': 29 // Handle cancellation 30 break; 31 } 32}; 33 34function heavyComputation(data: any): any { 35 // Report progress 36 for (let i = 0; i <= 100; i += 10) { 37 self.postMessage({ type: 'progress', data: i } as WorkerResponse); 38 } 39 40 return processedResult; 41} 42 43// Export empty to make it a module 44export {};
1// useWorker.ts 2import { useEffect, useRef, useState, useCallback } from 'react'; 3 4interface UseWorkerOptions<T, R> { 5 onResult?: (result: R) => void; 6 onProgress?: (progress: number) => void; 7 onError?: (error: string) => void; 8} 9 10function useWorker<T, R>( 11 workerFactory: () => Worker, 12 options: UseWorkerOptions<T, R> = {} 13) { 14 const workerRef = useRef<Worker | null>(null); 15 const [isProcessing, setIsProcessing] = useState(false); 16 const [progress, setProgress] = useState(0); 17 18 useEffect(() => { 19 workerRef.current = workerFactory(); 20 21 workerRef.current.onmessage = (event) => { 22 const { type, data } = event.data; 23 24 switch (type) { 25 case 'result': 26 setIsProcessing(false); 27 options.onResult?.(data); 28 break; 29 case 'progress': 30 setProgress(data); 31 options.onProgress?.(data); 32 break; 33 case 'error': 34 setIsProcessing(false); 35 options.onError?.(data); 36 break; 37 } 38 }; 39 40 return () => { 41 workerRef.current?.terminate(); 42 }; 43 }, [workerFactory]); 44 45 const process = useCallback((data: T) => { 46 setIsProcessing(true); 47 setProgress(0); 48 workerRef.current?.postMessage({ type: 'process', payload: data }); 49 }, []); 50 51 const cancel = useCallback(() => { 52 workerRef.current?.postMessage({ type: 'cancel' }); 53 setIsProcessing(false); 54 }, []); 55 56 return { process, cancel, isProcessing, progress }; 57} 58 59// Usage 60function DataProcessor() { 61 const [result, setResult] = useState(null); 62 63 const { process, isProcessing, progress } = useWorker<InputData, OutputData>( 64 () => new Worker(new URL('./worker.ts', import.meta.url)), 65 { 66 onResult: setResult, 67 onError: (error) => console.error(error), 68 } 69 ); 70 71 return ( 72 <div> 73 <button onClick={() => process(inputData)} disabled={isProcessing}> 74 Process 75 </button> 76 {isProcessing && <progress value={progress} max={100} />} 77 {result && <Results data={result} />} 78 </div> 79 ); 80}

Transferable Objects#

1// Transfer ownership for better performance 2// Useful for large ArrayBuffers 3 4// worker.js 5self.onmessage = (event) => { 6 const buffer = event.data; 7 8 // Process the buffer 9 const result = new Uint8Array(buffer); 10 for (let i = 0; i < result.length; i++) { 11 result[i] = result[i] * 2; 12 } 13 14 // Transfer back (buffer is moved, not copied) 15 self.postMessage(result.buffer, [result.buffer]); 16}; 17 18// main.js 19const buffer = new ArrayBuffer(1024 * 1024); // 1MB 20 21// Transfer ownership (buffer becomes unusable in main thread) 22worker.postMessage(buffer, [buffer]); 23 24// buffer.byteLength is now 0 25 26// Transferable types: 27// - ArrayBuffer 28// - MessagePort 29// - ImageBitmap 30// - OffscreenCanvas

Worker Pool#

1class WorkerPool { 2 private workers: Worker[] = []; 3 private taskQueue: Array<{ 4 data: any; 5 resolve: (value: any) => void; 6 reject: (error: Error) => void; 7 }> = []; 8 private availableWorkers: Worker[] = []; 9 10 constructor( 11 private workerScript: string, 12 private poolSize: number = navigator.hardwareConcurrency || 4 13 ) { 14 for (let i = 0; i < poolSize; i++) { 15 const worker = new Worker(workerScript); 16 this.workers.push(worker); 17 this.availableWorkers.push(worker); 18 } 19 } 20 21 exec<T>(data: any): Promise<T> { 22 return new Promise((resolve, reject) => { 23 const task = { data, resolve, reject }; 24 25 if (this.availableWorkers.length > 0) { 26 this.runTask(task); 27 } else { 28 this.taskQueue.push(task); 29 } 30 }); 31 } 32 33 private runTask(task: typeof this.taskQueue[0]) { 34 const worker = this.availableWorkers.pop()!; 35 36 worker.onmessage = (event) => { 37 task.resolve(event.data); 38 this.releaseWorker(worker); 39 }; 40 41 worker.onerror = (error) => { 42 task.reject(new Error(error.message)); 43 this.releaseWorker(worker); 44 }; 45 46 worker.postMessage(task.data); 47 } 48 49 private releaseWorker(worker: Worker) { 50 this.availableWorkers.push(worker); 51 52 if (this.taskQueue.length > 0) { 53 const nextTask = this.taskQueue.shift()!; 54 this.runTask(nextTask); 55 } 56 } 57 58 terminate() { 59 this.workers.forEach((worker) => worker.terminate()); 60 } 61} 62 63// Usage 64const pool = new WorkerPool('worker.js', 4); 65 66// Process multiple items in parallel 67const results = await Promise.all( 68 items.map((item) => pool.exec(item)) 69); 70 71pool.terminate();

Shared Worker#

1// shared-worker.js 2// Single worker shared across tabs/windows 3 4const connections: MessagePort[] = []; 5 6self.onconnect = (event: MessageEvent) => { 7 const port = event.ports[0]; 8 connections.push(port); 9 10 port.onmessage = (event) => { 11 const { type, data } = event.data; 12 13 switch (type) { 14 case 'broadcast': 15 // Send to all connected tabs 16 connections.forEach((conn) => { 17 conn.postMessage({ type: 'message', data }); 18 }); 19 break; 20 21 case 'sync': 22 // Synchronize state across tabs 23 port.postMessage({ type: 'state', data: sharedState }); 24 break; 25 } 26 }; 27 28 port.start(); 29}; 30 31// main.js 32const sharedWorker = new SharedWorker('shared-worker.js'); 33 34sharedWorker.port.onmessage = (event) => { 35 console.log('Received:', event.data); 36}; 37 38sharedWorker.port.start(); 39sharedWorker.port.postMessage({ type: 'broadcast', data: 'Hello from tab!' });
1// worker.ts with Comlink 2import * as Comlink from 'comlink'; 3 4const api = { 5 async heavyComputation(data: number[]): Promise<number> { 6 // Simulate heavy work 7 await new Promise((r) => setTimeout(r, 1000)); 8 return data.reduce((a, b) => a + b, 0); 9 }, 10 11 processImage(imageData: ImageData): ImageData { 12 // Process image pixels 13 const data = imageData.data; 14 for (let i = 0; i < data.length; i += 4) { 15 // Grayscale conversion 16 const avg = (data[i] + data[i + 1] + data[i + 2]) / 3; 17 data[i] = data[i + 1] = data[i + 2] = avg; 18 } 19 return imageData; 20 }, 21}; 22 23Comlink.expose(api); 24 25// main.ts 26import * as Comlink from 'comlink'; 27 28const worker = new Worker(new URL('./worker.ts', import.meta.url)); 29const api = Comlink.wrap<typeof import('./worker').api>(worker); 30 31// Use like regular async functions 32const result = await api.heavyComputation([1, 2, 3, 4, 5]); 33console.log(result); // 15

OffscreenCanvas#

1// Offload canvas rendering to worker 2// main.js 3const canvas = document.getElementById('canvas') as HTMLCanvasElement; 4const offscreen = canvas.transferControlToOffscreen(); 5 6const worker = new Worker('canvas-worker.js'); 7worker.postMessage({ canvas: offscreen }, [offscreen]); 8 9// canvas-worker.js 10let canvas: OffscreenCanvas; 11let ctx: OffscreenCanvasRenderingContext2D; 12 13self.onmessage = (event) => { 14 if (event.data.canvas) { 15 canvas = event.data.canvas; 16 ctx = canvas.getContext('2d')!; 17 render(); 18 } 19}; 20 21function render() { 22 // Render loop in worker 23 ctx.clearRect(0, 0, canvas.width, canvas.height); 24 25 // Draw complex graphics 26 for (let i = 0; i < 1000; i++) { 27 ctx.fillRect( 28 Math.random() * canvas.width, 29 Math.random() * canvas.height, 30 10, 31 10 32 ); 33 } 34 35 requestAnimationFrame(render); 36}

Best Practices#

When to Use: ✓ Heavy computations (sorting, parsing) ✓ Image/video processing ✓ Data transformation ✓ Complex calculations When NOT to Use: ✗ Simple operations ✗ DOM manipulation (not allowed) ✗ Small data sets ✗ Frequent small messages Performance: ✓ Use transferable objects ✓ Minimize message passing ✓ Pool workers for many tasks ✓ Terminate unused workers Communication: ✓ Structure messages clearly ✓ Handle errors properly ✓ Consider using Comlink ✓ Batch small messages

Conclusion#

Web Workers enable parallel processing in JavaScript without blocking the main thread. Use them for CPU-intensive tasks, leverage transferable objects for large data, and consider worker pools for concurrent processing. Libraries like Comlink simplify the communication API significantly.

Share this article

Help spread the word about Bootspring