Back to blog

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 thread

Transferable 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; // OK

Common 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.

Let's go! 🦅

Comments (0)

This article has no comments yet 😢. Be the first! 🚀🦅

Add comments