šŸ‘·
Advanced
12 min read

Web Workers & Multithreading

Offloading heavy computation to background threads

Understanding Web Workers

Web Workers allow you to run JavaScript in background threads, preventing long-running scripts from blocking the UI thread. This is essential for CPU-intensive operations.

Basic Web Worker Setup

// Main thread (main.js)
// āŒ Bad: CPU-intensive task blocks UI
function processLargeDataset(data) {
  // Complex calculations that freeze the UI
  return data.map(item => expensiveOperation(item));
}

// āœ… Good: Use Web Worker for heavy computation
const worker = new Worker('worker.js');

// Send data to worker
worker.postMessage({ data: largeDataset, operation: 'process' });

// Receive results from worker
worker.onmessage = (event) => {
  const results = event.data;
  displayResults(results);
};

// Handle errors
worker.onerror = (error) => {
  console.error('Worker error:', error.message);
};

// Terminate worker when done
// worker.terminate();
// Worker thread (worker.js)
// Listen for messages from main thread
self.onmessage = (event) => {
  const { data, operation } = event.data;
  
  let result;
  switch (operation) {
    case 'process':
      result = processData(data);
      break;
    case 'calculate':
      result = performCalculation(data);
      break;
    default:
      result = { error: 'Unknown operation' };
  }
  
  // Send result back to main thread
  self.postMessage(result);
};

function processData(data) {
  // CPU-intensive operations
  return data.map(item => {
    // Complex transformations
    return expensiveOperation(item);
  });
}

Transferable Objects

Transfer ownership of data instead of copying for better performance:

// āŒ Bad: Data is copied (slow for large arrays)
const buffer = new ArrayBuffer(1024 * 1024); // 1MB
worker.postMessage({ buffer: buffer });
// buffer is still accessible here (copied)

// āœ… Good: Transfer ownership (zero-copy)
const buffer = new ArrayBuffer(1024 * 1024);
worker.postMessage({ buffer: buffer }, [buffer]);
// buffer is no longer accessible here (transferred)

// Example with image processing
async function processImage(imageData) {
  const worker = new Worker('image-worker.js');
  
  return new Promise((resolve, reject) => {
    worker.onmessage = (e) => {
      resolve(e.data.processedImage);
      worker.terminate();
    };
    
    worker.onerror = reject;
    
    // Transfer ImageData buffer
    worker.postMessage({ imageData }, [imageData.data.buffer]);
  });
}

Shared Workers

Share a single worker instance across multiple browser contexts:

// Create shared worker
const sharedWorker = new SharedWorker('shared-worker.js');

// Communicate via port
sharedWorker.port.start();

sharedWorker.port.postMessage({ type: 'init', userId: 123 });

sharedWorker.port.onmessage = (event) => {
  console.log('Message from shared worker:', event.data);
};

// shared-worker.js
const connections = new Set();

self.onconnect = (event) => {
  const port = event.ports[0];
  connections.add(port);
  
  port.onmessage = (e) => {
    // Broadcast to all connections
    connections.forEach(conn => {
      if (conn !== port) {
        conn.postMessage(e.data);
      }
    });
  };
  
  port.start();
};

Worker Pool Pattern

class WorkerPool {
  constructor(workerScript, poolSize = 4) {
    this.workerScript = workerScript;
    this.poolSize = poolSize;
    this.workers = [];
    this.taskQueue = [];
    
    // Initialize worker pool
    for (let i = 0; i < poolSize; i++) {
      this.workers.push({
        worker: new Worker(workerScript),
        busy: false
      });
    }
  }
  
  async execute(data) {
    return new Promise((resolve, reject) => {
      const task = { data, resolve, reject };
      
      // Try to assign to available worker
      const availableWorker = this.workers.find(w => !w.busy);
      
      if (availableWorker) {
        this.runTask(availableWorker, task);
      } else {
        // Queue task if all workers busy
        this.taskQueue.push(task);
      }
    });
  }
  
  runTask(workerObj, task) {
    workerObj.busy = true;
    
    const handleMessage = (event) => {
      workerObj.busy = false;
      workerObj.worker.removeEventListener('message', handleMessage);
      task.resolve(event.data);
      
      // Process next task in queue
      if (this.taskQueue.length > 0) {
        const nextTask = this.taskQueue.shift();
        this.runTask(workerObj, nextTask);
      }
    };
    
    const handleError = (error) => {
      workerObj.busy = false;
      workerObj.worker.removeEventListener('error', handleError);
      task.reject(error);
    };
    
    workerObj.worker.addEventListener('message', handleMessage);
    workerObj.worker.addEventListener('error', handleError);
    workerObj.worker.postMessage(task.data);
  }
  
  terminate() {
    this.workers.forEach(w => w.worker.terminate());
    this.workers = [];
    this.taskQueue = [];
  }
}

// Usage
const pool = new WorkerPool('computation-worker.js', 4);

// Process multiple tasks in parallel
const promises = largeDataset.map(chunk => 
  pool.execute({ operation: 'process', data: chunk })
);

const results = await Promise.all(promises);
pool.terminate();

Real-World Use Cases

// 1. Image Processing
// image-worker.js
self.onmessage = (e) => {
  const { imageData, filter } = e.data;
  const filtered = applyFilter(imageData, filter);
  self.postMessage({ imageData: filtered }, [filtered.data.buffer]);
};

// 2. Data Parsing
// parser-worker.js
self.onmessage = (e) => {
  const { csv } = e.data;
  const parsed = parseCSV(csv); // CPU-intensive parsing
  self.postMessage({ data: parsed });
};

// 3. Cryptography
// crypto-worker.js
importScripts('crypto-lib.js');

self.onmessage = async (e) => {
  const { data, operation } = e.data;
  
  if (operation === 'encrypt') {
    const encrypted = await encrypt(data);
    self.postMessage({ encrypted });
  } else if (operation === 'decrypt') {
    const decrypted = await decrypt(data);
    self.postMessage({ decrypted });
  }
};

// 4. Real-time Data Processing
// analytics-worker.js
let dataBuffer = [];

self.onmessage = (e) => {
  dataBuffer.push(e.data);
  
  if (dataBuffer.length >= 1000) {
    const insights = analyzeData(dataBuffer);
    self.postMessage({ insights });
    dataBuffer = [];
  }
};

Worker Limitations

// Workers cannot access:
// - DOM (document, window)
// - Parent page objects
// - localStorage/sessionStorage (use IndexedDB instead)

// Workers CAN access:
// - setTimeout, setInterval
// - fetch, XMLHttpRequest
// - IndexedDB
// - WebSockets
// - importScripts() to load libraries

// Example: Load external libraries in worker
// worker.js
importScripts(
  'https://cdn.jsdelivr.net/npm/lodash@4.17.21/lodash.min.js',
  'https://cdn.jsdelivr.net/npm/date-fns@2.29.3/index.min.js'
);

self.onmessage = (e) => {
  // Can now use lodash and date-fns
  const result = _.groupBy(e.data, 'category');
  self.postMessage(result);
};

Best Practices

  • Use Web Workers for CPU-intensive tasks (image processing, data parsing, cryptography)
  • Transfer ArrayBuffers instead of copying when possible
  • Implement worker pools for handling multiple parallel tasks
  • Keep worker scripts small - use importScripts() for libraries
  • Always terminate workers when no longer needed to free memory
  • Handle errors gracefully with worker.onerror
  • Use Shared Workers for shared state across tabs
  • Consider using comlink library for easier Worker communication
  • Don't create workers in loops - reuse existing workers
  • Profile to ensure workers provide actual benefit (overhead exists)