DEV Community

HK Lee
HK Lee

Posted on • Originally published at pockit.tools

Why Your React App Feels Slow: Fixing Performance with Web Workers

Why Your React App Feels Slow: Fixing Performance with Web Workers

You know the feeling. You're building a feature that processes some data—maybe filtering a large list, parsing a CSV file, or running a calculation. In development with 100 records, it's instant. In production with 50,000 records, the entire UI freezes for 3 seconds. Users click buttons. Nothing happens. They click again. Still nothing. Then everything explodes at once.

Your main thread is dying, and your users are leaving.

This is the dirty secret of JavaScript: it's single-threaded. Every animation, every click handler, every API response, and every complex calculation fights for time on that single thread. When a heavy operation blocks it, your beautiful 60fps UI drops to 0fps. The browser literally cannot paint frames or respond to input.

The solution? Web Workers. Move that expensive work off the main thread entirely. Let the UI thread do what it does best—rendering and responding to users—while a background thread crunches numbers in parallel.

But here's the problem: most Web Worker tutorials show you a trivial "add two numbers" example and call it a day. When you try to use Workers in a real React application, you hit walls:

  • How do I share complex data structures?
  • What about TypeScript types?
  • How do I handle errors properly?
  • Can I use npm packages in Workers?
  • What's the right architecture for my app?

This guide answers all of that. We're going deep.

Understanding the Main Thread Problem

Before we fix the problem, let's understand it. JavaScript runs on a single thread called the "main thread." This thread handles:

  1. JavaScript execution (your code)
  2. DOM updates (rendering)
  3. User input processing (clicks, typing)
  4. Timers and animations (requestAnimationFrame, setTimeout)
  5. Network callbacks (fetch responses)

When you run a function that takes 500ms to complete, everything else waits. The browser can't repaint. It can't process clicks. From the user's perspective, the page is frozen.

// This blocks the main thread for ~500ms
function heavyComputation(data) {
  // Imagine processing 50,000 items
  return data.map(item => {
    // Complex transformation
    return expensiveOperation(item);
  });
}

// When this runs, the UI freezes
const result = heavyComputation(hugeDataset);
Enter fullscreen mode Exit fullscreen mode

You might think "just use async/await!" But that doesn't help here:

// Still blocks! async just helps with I/O, not CPU work
async function stillBlocking(data) {
  // This computation still runs on the main thread
  return data.map(item => expensiveOperation(item));
}
Enter fullscreen mode Exit fullscreen mode

The async keyword only helps when you're waiting for something external (network, disk). For CPU-intensive operations, you need actual parallelism. That's where Web Workers come in.

Web Workers 101: The Mental Model

A Web Worker is a separate JavaScript thread that runs in parallel with your main thread. It has its own event loop, its own global scope, and—critically—it cannot access the DOM.

┌─────────────────────────────────────────────────────────────┐
│                     BROWSER PROCESS                          │
├─────────────────────────┬───────────────────────────────────┤
│      MAIN THREAD        │         WORKER THREAD             │
├─────────────────────────┼───────────────────────────────────┤
│  • DOM access           │  • No DOM access                  │
│  • window object        │  • self object                    │
│  • User events          │  • Heavy computations             │
│  • Rendering            │  • Data processing                │
│  • React state          │  • Background tasks               │
├─────────────────────────┴───────────────────────────────────┤
│              postMessage() / onmessage                       │
│         (Communication via structured cloning)               │
└─────────────────────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

Communication between threads happens via postMessage() and onmessage. Data is copied (not shared) between threads using the "structured clone algorithm"—essentially a deep copy that supports most JavaScript types.

Here's the simplest possible Worker:

// worker.js
self.onmessage = function(event) {
  const data = event.data;
  const result = heavyComputation(data);
  self.postMessage(result);
};

function heavyComputation(data) {
  // Expensive work here
  return data.map(item => item * 2);
}
Enter fullscreen mode Exit fullscreen mode
// main.js
const worker = new Worker('worker.js');

worker.onmessage = function(event) {
  console.log('Result:', event.data);
};

worker.postMessage([1, 2, 3, 4, 5]);
Enter fullscreen mode Exit fullscreen mode

Simple enough. But this raises questions: How do we use this in React? How do we handle TypeScript? How do we share complex logic? Let's build a real solution.

Setting Up Web Workers in Modern React

If you're using Vite, Next.js, or Create React App, each has slightly different Worker support. Let's cover the modern approach that works across all of them.

Option 1: Inline Workers with Blob URLs

For simple cases, you can create Workers from inline code:

// utils/createWorker.ts
export function createWorkerFromFunction<T, R>(
  fn: (data: T) => R
): (data: T) => Promise<R> {
  const workerCode = `
    self.onmessage = function(e) {
      const fn = ${fn.toString()};
      const result = fn(e.data);
      self.postMessage(result);
    };
  `;

  const blob = new Blob([workerCode], { type: 'application/javascript' });
  const workerUrl = URL.createObjectURL(blob);
  const worker = new Worker(workerUrl);

  return (data: T): Promise<R> => {
    return new Promise((resolve, reject) => {
      worker.onmessage = (e) => resolve(e.data);
      worker.onerror = (e) => reject(e);
      worker.postMessage(data);
    });
  };
}
Enter fullscreen mode Exit fullscreen mode

Usage:

const processInWorker = createWorkerFromFunction((numbers: number[]) => {
  return numbers.map(n => n * n).reduce((a, b) => a + b, 0);
});

// Now this runs in a Worker!
const sum = await processInWorker([1, 2, 3, 4, 5]);
Enter fullscreen mode Exit fullscreen mode

Limitation: The function can't import other modules or use closures. It must be self-contained.

Option 2: Vite's Native Worker Support

Vite has excellent Worker support with the ?worker suffix:

// workers/dataProcessor.worker.ts
export interface WorkerInput {
  data: number[];
  operation: 'sum' | 'average' | 'max';
}

export interface WorkerOutput {
  result: number;
  processingTime: number;
}

self.onmessage = (event: MessageEvent<WorkerInput>) => {
  const start = performance.now();
  const { data, operation } = event.data;

  let result: number;
  switch (operation) {
    case 'sum':
      result = data.reduce((a, b) => a + b, 0);
      break;
    case 'average':
      result = data.reduce((a, b) => a + b, 0) / data.length;
      break;
    case 'max':
      result = Math.max(...data);
      break;
  }

  const output: WorkerOutput = {
    result,
    processingTime: performance.now() - start,
  };

  self.postMessage(output);
};
Enter fullscreen mode Exit fullscreen mode
// hooks/useDataProcessor.ts
import { useCallback, useRef, useEffect } from 'react';
import DataProcessorWorker from '../workers/dataProcessor.worker?worker';
import type { WorkerInput, WorkerOutput } from '../workers/dataProcessor.worker';

export function useDataProcessor() {
  const workerRef = useRef<Worker | null>(null);

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

  const process = useCallback((input: WorkerInput): Promise<WorkerOutput> => {
    return new Promise((resolve, reject) => {
      if (!workerRef.current) {
        reject(new Error('Worker not initialized'));
        return;
      }

      workerRef.current.onmessage = (e) => resolve(e.data);
      workerRef.current.onerror = (e) => reject(e);
      workerRef.current.postMessage(input);
    });
  }, []);

  return { process };
}
Enter fullscreen mode Exit fullscreen mode

Option 3: Comlink for Cleaner APIs

Comlink by Google Chrome Labs makes Workers feel like regular async functions:

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

const api = {
  async processImage(imageData: ImageData): Promise<ImageData> {
    // Heavy image processing
    const pixels = imageData.data;
    for (let i = 0; i < pixels.length; i += 4) {
      // Grayscale conversion
      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
    }
    return imageData;
  },

  async analyzeData(data: number[]): Promise<{
    mean: number;
    stdDev: number;
    median: number;
  }> {
    const sorted = [...data].sort((a, b) => a - b);
    const mean = data.reduce((a, b) => a + b, 0) / data.length;
    const squaredDiffs = data.map(x => Math.pow(x - mean, 2));
    const variance = squaredDiffs.reduce((a, b) => a + b, 0) / data.length;

    return {
      mean,
      stdDev: Math.sqrt(variance),
      median: sorted[Math.floor(sorted.length / 2)],
    };
  },
};

Comlink.expose(api);
export type WorkerApi = typeof api;
Enter fullscreen mode Exit fullscreen mode
// hooks/useImageProcessor.ts
import { useEffect, useRef } from 'react';
import * as Comlink from 'comlink';
import type { WorkerApi } from '../workers/imageProcessor.worker';

export function useImageProcessor() {
  const workerRef = useRef<Comlink.Remote<WorkerApi> | null>(null);

  useEffect(() => {
    const worker = new Worker(
      new URL('../workers/imageProcessor.worker.ts', import.meta.url),
      { type: 'module' }
    );
    workerRef.current = Comlink.wrap<WorkerApi>(worker);

    return () => worker.terminate();
  }, []);

  return workerRef.current;
}

// Usage in component
function ImageEditor() {
  const processor = useImageProcessor();

  const handleProcess = async () => {
    if (!processor) return;

    // Looks like a normal async call!
    const result = await processor.processImage(imageData);
    setProcessedImage(result);
  };
}
Enter fullscreen mode Exit fullscreen mode

Comlink handles all the postMessage plumbing and makes Workers feel natural.

Real-World Example: CSV Parser That Doesn't Freeze

Let's build something practical. Imagine you're building an app that imports CSV files. Users upload a 50MB CSV with 500,000 rows. Without Workers, parsing this freezes the UI for 5-10 seconds.

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

interface ParseOptions {
  delimiter?: string;
  hasHeader?: boolean;
}

interface ParseResult {
  headers: string[];
  rows: string[][];
  rowCount: number;
  parseTimeMs: number;
}

interface ProgressCallback {
  (progress: number): void;
}

const csvParser = {
  async parse(
    csvText: string,
    options: ParseOptions = {},
    onProgress?: ProgressCallback
  ): Promise<ParseResult> {
    const start = performance.now();
    const { delimiter = ',', hasHeader = true } = options;

    const lines = csvText.split('\n');
    const totalLines = lines.length;
    const headers: string[] = [];
    const rows: string[][] = [];

    for (let i = 0; i < lines.length; i++) {
      const line = lines[i].trim();
      if (!line) continue;

      const values = parseCsvLine(line, delimiter);

      if (i === 0 && hasHeader) {
        headers.push(...values);
      } else {
        rows.push(values);
      }

      // Report progress every 1000 rows
      if (onProgress && i % 1000 === 0) {
        onProgress(Math.round((i / totalLines) * 100));
      }
    }

    return {
      headers,
      rows,
      rowCount: rows.length,
      parseTimeMs: performance.now() - start,
    };
  },

  async filter(
    rows: string[][],
    columnIndex: number,
    predicate: string,
    onProgress?: ProgressCallback
  ): Promise<string[][]> {
    const result: string[][] = [];

    for (let i = 0; i < rows.length; i++) {
      const value = rows[i][columnIndex];
      if (value?.toLowerCase().includes(predicate.toLowerCase())) {
        result.push(rows[i]);
      }

      if (onProgress && i % 1000 === 0) {
        onProgress(Math.round((i / rows.length) * 100));
      }
    }

    return result;
  },

  async aggregate(
    rows: string[][],
    groupByColumn: number,
    aggregateColumn: number
  ): Promise<Map<string, number>> {
    const groups = new Map<string, number[]>();

    for (const row of rows) {
      const key = row[groupByColumn];
      const value = parseFloat(row[aggregateColumn]);

      if (!isNaN(value)) {
        if (!groups.has(key)) {
          groups.set(key, []);
        }
        groups.get(key)!.push(value);
      }
    }

    const result = new Map<string, number>();
    for (const [key, values] of groups) {
      result.set(key, values.reduce((a, b) => a + b, 0));
    }

    return result;
  },
};

function parseCsvLine(line: string, delimiter: string): string[] {
  const result: string[] = [];
  let current = '';
  let inQuotes = false;

  for (let i = 0; i < line.length; i++) {
    const char = line[i];

    if (char === '"') {
      inQuotes = !inQuotes;
    } else if (char === delimiter && !inQuotes) {
      result.push(current.trim());
      current = '';
    } else {
      current += char;
    }
  }

  result.push(current.trim());
  return result;
}

Comlink.expose(csvParser);
export type CsvParser = typeof csvParser;
Enter fullscreen mode Exit fullscreen mode
// hooks/useCsvParser.ts
import { useEffect, useRef, useState, useCallback } from 'react';
import * as Comlink from 'comlink';
import type { CsvParser } from '../workers/csvParser.worker';

export function useCsvParser() {
  const workerRef = useRef<Comlink.Remote<CsvParser> | null>(null);
  const [progress, setProgress] = useState(0);
  const [isProcessing, setIsProcessing] = useState(false);

  useEffect(() => {
    const worker = new Worker(
      new URL('../workers/csvParser.worker.ts', import.meta.url),
      { type: 'module' }
    );
    workerRef.current = Comlink.wrap<CsvParser>(worker);
    return () => worker.terminate();
  }, []);

  const parseFile = useCallback(async (file: File) => {
    if (!workerRef.current) throw new Error('Worker not ready');

    setIsProcessing(true);
    setProgress(0);

    try {
      const text = await file.text();
      const result = await workerRef.current.parse(
        text,
        { hasHeader: true },
        Comlink.proxy((p: number) => setProgress(p))
      );
      return result;
    } finally {
      setIsProcessing(false);
      setProgress(100);
    }
  }, []);

  return {
    parseFile,
    progress,
    isProcessing,
    parser: workerRef.current,
  };
}
Enter fullscreen mode Exit fullscreen mode
// components/CsvImporter.tsx
import { useState } from 'react';
import { useCsvParser } from '../hooks/useCsvParser';

export function CsvImporter() {
  const { parseFile, progress, isProcessing } = useCsvParser();
  const [result, setResult] = useState<ParseResult | null>(null);

  const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
    const file = e.target.files?.[0];
    if (!file) return;

    const parsed = await parseFile(file);
    setResult(parsed);
  };

  return (
    <div>
      <input 
        type="file" 
        accept=".csv" 
        onChange={handleFileChange}
        disabled={isProcessing}
      />

      {isProcessing && (
        <div className="progress-bar">
          <div 
            className="progress-fill" 
            style={{ width: `${progress}%` }}
          />
          <span>Processing... {progress}%</span>
        </div>
      )}

      {result && (
        <div className="result">
          <p>Parsed {result.rowCount.toLocaleString()} rows</p>
          <p>Time: {result.parseTimeMs.toFixed(2)}ms</p>
          <table>
            <thead>
              <tr>
                {result.headers.map((h, i) => (
                  <th key={i}>{h}</th>
                ))}
              </tr>
            </thead>
            <tbody>
              {result.rows.slice(0, 100).map((row, i) => (
                <tr key={i}>
                  {row.map((cell, j) => (
                    <td key={j}>{cell}</td>
                  ))}
                </tr>
              ))}
            </tbody>
          </table>
        </div>
      )}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Now the CSV parsing happens in the background. The UI stays responsive. Users see progress updates. No freezing.

Handling Large Data: Transferable Objects

Copying large amounts of data between threads is slow. For ArrayBuffer, MessagePort, and OffscreenCanvas, you can use transferable objects to move data without copying:

// Instead of copying, transfer ownership
const buffer = new ArrayBuffer(1024 * 1024 * 100); // 100MB

// Slow: copies the entire buffer
worker.postMessage({ buffer });

// Fast: transfers ownership (buffer becomes unusable in main thread)
worker.postMessage({ buffer }, [buffer]);
Enter fullscreen mode Exit fullscreen mode

For image processing, this is huge:

// workers/imageProcessor.worker.ts
self.onmessage = async (e: MessageEvent) => {
  const { imageData } = e.data;

  // Process the image data
  const processed = applyFilter(imageData);

  // Transfer back instead of copying
  const buffer = processed.data.buffer;
  self.postMessage({ imageData: processed }, [buffer]);
};
Enter fullscreen mode Exit fullscreen mode
// main thread
const offscreen = canvas.transferControlToOffscreen();
worker.postMessage({ canvas: offscreen }, [offscreen]);
Enter fullscreen mode Exit fullscreen mode

Worker Pooling for Parallel Processing

One Worker is good. Multiple Workers processing in parallel is better. Here's a simple Worker pool:

// utils/WorkerPool.ts
export class WorkerPool<TInput, TOutput> {
  private workers: Worker[] = [];
  private queue: Array<{
    data: TInput;
    resolve: (result: TOutput) => void;
    reject: (error: Error) => void;
  }> = [];
  private activeWorkers = new Set<Worker>();

  constructor(
    private createWorker: () => Worker,
    private poolSize: number = navigator.hardwareConcurrency || 4
  ) {
    for (let i = 0; i < poolSize; i++) {
      this.workers.push(this.createWorker());
    }
  }

  async execute(data: TInput): Promise<TOutput> {
    return new Promise((resolve, reject) => {
      const availableWorker = this.workers.find(
        w => !this.activeWorkers.has(w)
      );

      if (availableWorker) {
        this.runTask(availableWorker, data, resolve, reject);
      } else {
        this.queue.push({ data, resolve, reject });
      }
    });
  }

  private runTask(
    worker: Worker,
    data: TInput,
    resolve: (result: TOutput) => void,
    reject: (error: Error) => void
  ) {
    this.activeWorkers.add(worker);

    worker.onmessage = (e) => {
      resolve(e.data);
      this.activeWorkers.delete(worker);
      this.processQueue();
    };

    worker.onerror = (e) => {
      reject(new Error(e.message));
      this.activeWorkers.delete(worker);
      this.processQueue();
    };

    worker.postMessage(data);
  }

  private processQueue() {
    if (this.queue.length === 0) return;

    const availableWorker = this.workers.find(
      w => !this.activeWorkers.has(w)
    );

    if (availableWorker) {
      const { data, resolve, reject } = this.queue.shift()!;
      this.runTask(availableWorker, data, resolve, reject);
    }
  }

  async executeAll(items: TInput[]): Promise<TOutput[]> {
    return Promise.all(items.map(item => this.execute(item)));
  }

  terminate() {
    this.workers.forEach(w => w.terminate());
    this.workers = [];
  }
}
Enter fullscreen mode Exit fullscreen mode

Usage for parallel image processing:

const pool = new WorkerPool(
  () => new Worker(new URL('./imageWorker.ts', import.meta.url)),
  4 // 4 parallel workers
);

// Process 100 images in parallel across 4 workers
const results = await pool.executeAll(images);
Enter fullscreen mode Exit fullscreen mode

Common Pitfalls and How to Avoid Them

Pitfall 1: Overusing Workers for Small Tasks

Workers have overhead. Creating a Worker, serializing data, and deserializing results takes time. For small operations, this overhead can exceed the computation time.

Rule of thumb: Only use Workers when the operation takes >16ms (one frame at 60fps).

// ❌ Don't do this - overhead exceeds benefit
worker.postMessage([1, 2, 3]); // Summing 3 numbers

// ✅ Do this - justify the overhead
worker.postMessage(bigArray); // Processing 100,000 items
Enter fullscreen mode Exit fullscreen mode

Pitfall 2: Forgetting to Terminate Workers

Workers consume memory. If you create new Workers without terminating old ones, you'll leak memory:

// ❌ Memory leak
function processData(data) {
  const worker = new Worker('worker.js');
  worker.postMessage(data);
  // Worker never terminated!
}

// ✅ Clean up properly
function processData(data) {
  const worker = new Worker('worker.js');
  worker.onmessage = (e) => {
    console.log(e.data);
    worker.terminate(); // Clean up when done
  };
  worker.postMessage(data);
}
Enter fullscreen mode Exit fullscreen mode

In React, always terminate in cleanup:

useEffect(() => {
  const worker = new Worker('worker.js');
  workerRef.current = worker;

  return () => {
    worker.terminate(); // ✅ Cleanup on unmount
  };
}, []);
Enter fullscreen mode Exit fullscreen mode

Pitfall 3: Not Handling Errors

Worker errors don't automatically propagate to your main thread error handlers:

// ❌ Errors silently ignored
worker.postMessage(data);

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

worker.onmessageerror = (error) => {
  console.error('Message error:', error);
};
Enter fullscreen mode Exit fullscreen mode

Pitfall 4: Blocking the Worker Thread

Just because you moved work to a Worker doesn't mean you can't still block it. If your Worker does 5 seconds of synchronous work, it can't respond to other messages during that time.

// ❌ Long-running synchronous task blocks all messages
self.onmessage = (e) => {
  const result = process5MillionItems(e.data); // Blocks for 5 seconds
  self.postMessage(result);
};

// ✅ Chunk the work and yield periodically
self.onmessage = async (e) => {
  const items = e.data;
  const results = [];
  const chunkSize = 10000;

  for (let i = 0; i < items.length; i += chunkSize) {
    const chunk = items.slice(i, i + chunkSize);
    results.push(...processChunk(chunk));

    // Yield to allow other messages to be processed
    await new Promise(resolve => setTimeout(resolve, 0));

    // Report progress
    self.postMessage({ type: 'progress', value: i / items.length });
  }

  self.postMessage({ type: 'complete', results });
};
Enter fullscreen mode Exit fullscreen mode

When NOT to Use Web Workers

Workers aren't always the answer. Avoid them when:

  1. The operation is I/O bound, not CPU bound - Fetching data, database queries. Use async/await instead.

  2. You need DOM access - Workers can't touch the DOM. If you need to update UI during processing, you must message the main thread.

  3. The data transfer cost exceeds computation savings - Serializing 1GB of data to save 100ms of computation isn't worth it.

  4. The operation is already fast - If it completes in <16ms, the overhead isn't worth it.

  5. You're targeting older browsers without fallback - Check your browser support requirements.

Performance Comparison: Before and After

Let's measure the impact with a real example—sorting 1 million numbers:

// Without Worker
function sortData() {
  const start = performance.now();
  const sorted = data.sort((a, b) => a - b);
  console.log(`Main thread: ${performance.now() - start}ms`);
  console.log(`UI frozen during this time`);
}

// With Worker
async function sortDataWorker() {
  const start = performance.now();
  const sorted = await workerSort(data);
  console.log(`Worker: ${performance.now() - start}ms`);
  console.log(`UI remained responsive`);
}
Enter fullscreen mode Exit fullscreen mode

Results on a typical machine:

  • Without Worker: 1,200ms (UI frozen)
  • With Worker: 1,250ms (UI responsive)

The total time is slightly longer with Workers due to serialization overhead. But the user experience is dramatically better because the UI never freezes.

Conclusion: Move the Pain Off Main Thread

Web Workers are one of the most underutilized browser APIs. When used correctly, they transform applications that feel sluggish into ones that feel instant and responsive.

Key takeaways:

  1. The main thread is precious - Reserve it for rendering and user interaction.

  2. Workers are for CPU-bound work - Data processing, parsing, calculations, image manipulation.

  3. Use Comlink for cleaner APIs - Makes Workers feel like regular async functions.

  4. Transfer, don't copy - Use transferable objects for large ArrayBuffers.

  5. Pool Workers for parallelism - Multiple workers can process data in parallel.

  6. Measure before optimizing - Workers have overhead. Only use them when the benefit exceeds the cost.

  7. Don't forget cleanup - Always terminate Workers when you're done.

The next time your React app stutters during a heavy operation, you'll know exactly what to do. Move that work to a Worker, keep the main thread free, and give your users the smooth experience they deserve.


Speed Tip: Read the original post on the Pockit Blog.

Tired of slow cloud tools? Pockit.tools runs entirely in your browser. Get the Extension now for instant, zero-latency access to essential dev tools.

Top comments (0)