Skip to content

Web Workers

True parallelism in the browser using separate threads.

When to Use Web Workers

  • CPU-intensive tasks — Image processing, data parsing, calculations
  • Keep UI responsive — Offload heavy work from main thread
  • Background processing — Tasks that don't need immediate UI feedback

Not for: - Simple async operations (use promises) - DOM manipulation (workers can't access DOM) - Quick computations (overhead > benefit)

Basic Web Worker

Main Script

// main.js
const worker = new Worker('worker.js');

// Send data to worker
worker.postMessage({ data: [1, 2, 3, 4, 5], operation: 'sum' });

// Receive results
worker.onmessage = (event) => {
  console.log('Result:', event.data);
};

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

// Terminate when done
worker.terminate();

Worker Script

// worker.js
self.onmessage = (event) => {
  const { data, operation } = event.data;

  let result;
  switch (operation) {
    case 'sum':
      result = data.reduce((a, b) => a + b, 0);
      break;
    case 'sort':
      result = [...data].sort((a, b) => a - b);
      break;
    default:
      result = null;
  }

  // Send result back
  self.postMessage(result);
};

Inline Workers

Create workers without separate files.

function createWorker(fn: () => void): Worker {
  const blob = new Blob(
    [`(${fn.toString()})()`],
    { type: 'application/javascript' }
  );
  return new Worker(URL.createObjectURL(blob));
}

// Usage
const worker = createWorker(() => {
  self.onmessage = (e) => {
    const result = e.data.map((x: number) => x * 2);
    self.postMessage(result);
  };
});

worker.postMessage([1, 2, 3]);
worker.onmessage = (e) => console.log(e.data); // [2, 4, 6]

Worker with TypeScript

Types

// worker.types.ts
export interface WorkerMessage {
  type: 'PROCESS' | 'CANCEL';
  payload: any;
}

export interface WorkerResponse {
  type: 'RESULT' | 'ERROR' | 'PROGRESS';
  payload: any;
}

Worker

// worker.ts
import type { WorkerMessage, WorkerResponse } from './worker.types';

self.onmessage = (event: MessageEvent<WorkerMessage>) => {
  const { type, payload } = event.data;

  try {
    switch (type) {
      case 'PROCESS':
        const result = processData(payload);
        const response: WorkerResponse = { type: 'RESULT', payload: result };
        self.postMessage(response);
        break;
      case 'CANCEL':
        // Handle cancellation
        break;
    }
  } catch (error) {
    const errorResponse: WorkerResponse = {
      type: 'ERROR',
      payload: error instanceof Error ? error.message : 'Unknown error',
    };
    self.postMessage(errorResponse);
  }
};

function processData(data: number[]): number {
  return data.reduce((sum, n) => sum + n * n, 0);
}

Main

// main.ts
import type { WorkerMessage, WorkerResponse } from './worker.types';

class TypedWorker {
  private worker: Worker;

  constructor() {
    this.worker = new Worker(new URL('./worker.ts', import.meta.url));
  }

  process(data: number[]): Promise<number> {
    return new Promise((resolve, reject) => {
      const handler = (event: MessageEvent<WorkerResponse>) => {
        const { type, payload } = event.data;
        this.worker.removeEventListener('message', handler);

        if (type === 'RESULT') {
          resolve(payload);
        } else if (type === 'ERROR') {
          reject(new Error(payload));
        }
      };

      this.worker.addEventListener('message', handler);
      const message: WorkerMessage = { type: 'PROCESS', payload: data };
      this.worker.postMessage(message);
    });
  }

  terminate() {
    this.worker.terminate();
  }
}

// Usage
const worker = new TypedWorker();
const result = await worker.process([1, 2, 3, 4, 5]);
console.log(result); // 55

Simplify worker communication with Comlink.

bun add comlink

Worker

// worker.ts
import * as Comlink from 'comlink';

const api = {
  async processData(data: number[]): Promise<number> {
    return data.reduce((sum, n) => sum + n * n, 0);
  },

  async sortLargeArray(arr: number[]): Promise<number[]> {
    return [...arr].sort((a, b) => a - b);
  },
};

Comlink.expose(api);

Main

// main.ts
import * as Comlink from 'comlink';

const worker = new Worker(new URL('./worker.ts', import.meta.url));
const api = Comlink.wrap<typeof import('./worker')['api']>(worker);

// Call methods like regular async functions
const result = await api.processData([1, 2, 3, 4, 5]);
console.log(result); // 55

const sorted = await api.sortLargeArray([5, 2, 8, 1, 9]);
console.log(sorted); // [1, 2, 5, 8, 9]

Transferable Objects

Transfer ownership instead of copying for better performance.

// main.js
const buffer = new ArrayBuffer(1024 * 1024); // 1MB

// Bad: Copies the buffer (slow)
worker.postMessage({ buffer });

// Good: Transfers ownership (fast)
worker.postMessage({ buffer }, [buffer]);
// buffer is now unusable in main thread

// worker.js
self.onmessage = (event) => {
  const { buffer } = event.data;
  // buffer is now owned by worker

  // Process...
  const result = new ArrayBuffer(1024);

  // Transfer back
  self.postMessage({ result }, [result]);
};

Transferable Types

  • ArrayBuffer
  • MessagePort
  • ImageBitmap
  • OffscreenCanvas

Shared Memory

SharedArrayBuffer allows shared memory between threads.

// main.js
const sharedBuffer = new SharedArrayBuffer(1024);
const sharedArray = new Int32Array(sharedBuffer);

worker.postMessage({ sharedArray });

// Both main thread and worker can access sharedArray
sharedArray[0] = 42;

// worker.js
self.onmessage = (event) => {
  const { sharedArray } = event.data;

  // Atomic operations for thread safety
  Atomics.add(sharedArray, 0, 1);

  const value = Atomics.load(sharedArray, 0);
  console.log(value); // 43
};

Atomics

// Thread-safe operations
Atomics.add(array, index, value);
Atomics.sub(array, index, value);
Atomics.load(array, index);
Atomics.store(array, index, value);
Atomics.exchange(array, index, value);
Atomics.compareExchange(array, index, expected, replacement);

// Synchronization
Atomics.wait(array, index, value, timeout);
Atomics.notify(array, index, count);

Worker Pool

Manage multiple workers for parallel processing.

class WorkerPool {
  private workers: Worker[] = [];
  private queue: Array<{
    data: any;
    resolve: (value: any) => void;
    reject: (error: Error) => void;
  }> = [];
  private busyWorkers = new Set<Worker>();

  constructor(workerScript: string, poolSize: number) {
    for (let i = 0; i < poolSize; i++) {
      const worker = new Worker(workerScript);
      this.workers.push(worker);

      worker.onmessage = (event) => {
        this.busyWorkers.delete(worker);
        this.processQueue();
      };
    }
  }

  async execute<T>(data: any): Promise<T> {
    return new Promise((resolve, reject) => {
      this.queue.push({ data, resolve, reject });
      this.processQueue();
    });
  }

  private processQueue() {
    const availableWorker = this.workers.find(w => !this.busyWorkers.has(w));
    const task = this.queue.shift();

    if (availableWorker && task) {
      this.busyWorkers.add(availableWorker);

      const handler = (event: MessageEvent) => {
        availableWorker.removeEventListener('message', handler);
        task.resolve(event.data);
      };

      availableWorker.addEventListener('message', handler);
      availableWorker.postMessage(task.data);
    }
  }

  terminate() {
    this.workers.forEach(w => w.terminate());
  }
}

// Usage
const pool = new WorkerPool('worker.js', 4);
const results = await Promise.all(
  items.map(item => pool.execute(item))
);

React Integration

useWorker Hook

import { useCallback, useEffect, useRef, useState } from 'react';

function useWorker<T, R>(workerFactory: () => Worker) {
  const workerRef = useRef<Worker | null>(null);
  const [result, setResult] = useState<R | null>(null);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<Error | null>(null);

  useEffect(() => {
    workerRef.current = workerFactory();

    workerRef.current.onmessage = (event: MessageEvent<R>) => {
      setResult(event.data);
      setLoading(false);
    };

    workerRef.current.onerror = (err) => {
      setError(new Error(err.message));
      setLoading(false);
    };

    return () => {
      workerRef.current?.terminate();
    };
  }, [workerFactory]);

  const run = useCallback((data: T) => {
    setLoading(true);
    setError(null);
    workerRef.current?.postMessage(data);
  }, []);

  return { result, loading, error, run };
}

// Usage
function Component() {
  const { result, loading, error, run } = useWorker<number[], number>(
    () => new Worker(new URL('./worker.ts', import.meta.url))
  );

  return (
    <button onClick={() => run([1, 2, 3, 4, 5])} disabled={loading}>
      {loading ? 'Processing...' : `Result: ${result}`}
    </button>
  );
}

Best Practices

  1. Use for heavy computation — Don't over-engineer simple tasks
  2. Transfer, don't copy — Use Transferable for large data
  3. Pool workers — Reuse workers to avoid creation overhead
  4. Handle errors — Workers fail silently without error handlers
  5. Terminate when done — Clean up workers to free resources
  6. Consider Comlink — Simplifies worker communication
  7. Test worker logic — Extract pure functions for testing

Limitations

  • No DOM access — Workers can't manipulate the DOM
  • Limited APIs — No window, document, parent
  • Serialization cost — postMessage serializes data
  • Same-origin — Worker scripts must be same-origin
  • Module support varies — Check browser compatibility