Skip to content
Codeloom
Web

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.

·8 min read · By Codeloom
Intermediate 13 min read

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]
Main thread vs Worker thread

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 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);
// 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

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