DEV Community

Atlas Whoff
Atlas Whoff

Posted on

Web Workers: Running CPU-Intensive Code Without Freezing the UI

Web Workers: Running CPU-Intensive Code Without Freezing the UI

JavaScript is single-threaded. Heavy computation on the main thread blocks the UI — no animations, no input response, nothing until the work is done.

Web Workers run JavaScript in a background thread, keeping the UI responsive while heavy work happens in parallel.

When to Use Workers

Use a Web Worker when a task takes more than ~16ms (one frame) and involves:

  • Parsing large JSON or CSV files
  • Image processing or canvas manipulation
  • Cryptographic operations
  • Complex data transformations or sorting
  • Any tight loop over large datasets

Basic Worker Setup

// worker.ts — runs in a separate thread
self.addEventListener('message', (event: MessageEvent) => {
  const { data, operation } = event.data;

  if (operation === 'sort') {
    // This can take 500ms without freezing the UI
    const sorted = data.sort((a: number, b: number) => a - b);
    self.postMessage({ result: sorted });
  }
});
Enter fullscreen mode Exit fullscreen mode
// main.ts — UI thread
const worker = new Worker(new URL('./worker.ts', import.meta.url), { type: 'module' });

worker.addEventListener('message', (event) => {
  const { result } = event.data;
  setData(result); // Update UI with sorted data
});

function sortInBackground(data: number[]) {
  worker.postMessage({ operation: 'sort', data });
  // Returns immediately — UI stays responsive
}
Enter fullscreen mode Exit fullscreen mode

Transferable Objects (Zero-Copy)

By default, data is copied when sent to/from a worker (structured clone). For large ArrayBuffers, use transferable objects to move ownership instead:

// Transfer ownership of buffer — no copy, instant
const buffer = new ArrayBuffer(100 * 1024 * 1024); // 100MB
worker.postMessage({ buffer }, [buffer]); // buffer is now owned by worker
// buffer is no longer accessible here (transferred)
Enter fullscreen mode Exit fullscreen mode

Comlink: The Clean API

Raw postMessage is verbose. Comlink wraps workers in a proxy that looks like a regular async function call:

// worker.ts
import { expose } from 'comlink';

const api = {
  async processImage(imageData: ImageData): Promise<ImageData> {
    // Heavy image processing
    return applyFilter(imageData);
  },
  async parseCSV(csv: string): Promise<Record<string, string>[]> {
    return parseAndTransform(csv);
  },
};

expose(api);
Enter fullscreen mode Exit fullscreen mode
// main.ts
import { wrap } from 'comlink';

const worker = new Worker(new URL('./worker.ts', import.meta.url), { type: 'module' });
const api = wrap<typeof api>(worker);

// Looks like a normal async call — runs in worker thread
const result = await api.parseCSV(csvString);
Enter fullscreen mode Exit fullscreen mode

Worker Pool for Parallel Tasks

For multiple independent tasks, a worker pool saturates all CPU cores:

class WorkerPool {
  private workers: Worker[] = [];
  private queue: Array<{ resolve: Function; reject: Function; task: any }> = [];
  private idle: Worker[] = [];

  constructor(size = navigator.hardwareConcurrency) {
    for (let i = 0; i < size; i++) {
      const w = new Worker(new URL('./worker.ts', import.meta.url), { type: 'module' });
      this.workers.push(w);
      this.idle.push(w);
    }
  }

  run(task: any): Promise<any> {
    return new Promise((resolve, reject) => {
      const worker = this.idle.pop();
      if (worker) {
        this.execute(worker, task, resolve, reject);
      } else {
        this.queue.push({ resolve, reject, task });
      }
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

The Ship Fast Pattern

Web Workers, optimistic updates, code splitting, and the other performance patterns that separate fast apps from slow ones are part of the Ship Fast Skill Pack — a set of Claude Code skills that implement these patterns as reusable slash commands.

Top comments (0)