35 Web Workers – Nebenläufigkeit im Browser

JavaScript ist single-threaded. Der Browser-Main-Thread erledigt alles: UI-Rendering, Event-Handling, JavaScript-Execution, Layout-Berechnung. Eine schwere Berechnung blockiert diesen Thread – die UI friert ein, Animationen stocken, Clicks werden ignoriert. Der User denkt: “App abgestürzt”.

Web Workers lösen dieses Problem durch echte Parallelität. Sie laufen in separaten Threads mit eigener Event-Loop, komplett isoliert vom Main Thread. Schwere Operationen wandern in den Worker, der Main Thread bleibt responsiv.

35.1 Das Problem: Blocking Operations im Main Thread

// ❌ Fibonacci-Berechnung blockiert UI
function Dashboard() {
  const [result, setResult] = useState<number>(0);
  const [loading, setLoading] = useState(false);
  
  const calculate = () => {
    setLoading(true);
    
    // Rekursive Fibonacci - sehr langsam für große n
    const fibonacci = (n: number): number => {
      if (n <= 1) return n;
      return fibonacci(n - 1) + fibonacci(n - 2);
    };
    
    const result = fibonacci(45);  // Dauert ~5-10 Sekunden
    setResult(result);
    setLoading(false);
  };
  
  return (
    <div>
      <button onClick={calculate}>Calculate Fibonacci(45)</button>
      {loading && <p>Calculating...</p>}
      <p>Result: {result}</p>
      
      {/* Diese Animation stockt während Berechnung */}
      <div className="spinner" />
    </div>
  );
}

Problem: Während fibonacci(45) läuft: - UI reagiert nicht auf Klicks - Animationen frieren ein - Browser zeigt “Page unresponsive” Warning - User denkt die App ist kaputt

35.2 Web Workers: Separate Threads für Heavy Tasks

Ein Web Worker ist ein separates JavaScript-File, das in eigenem Thread läuft. Kommunikation erfolgt über Messages – kein Shared Memory.

// workers/fibonacci.worker.ts
self.addEventListener('message', (event: MessageEvent<number>) => {
  const n = event.data;
  
  const fibonacci = (n: number): number => {
    if (n <= 1) return n;
    return fibonacci(n - 1) + fibonacci(n - 2);
  };
  
  const result = fibonacci(n);
  
  // Ergebnis zurück an Main Thread
  self.postMessage(result);
});
// Dashboard.tsx
import { useEffect, useState } from 'react';

function Dashboard() {
  const [result, setResult] = useState<number>(0);
  const [loading, setLoading] = useState(false);
  const [worker, setWorker] = useState<Worker | null>(null);
  
  useEffect(() => {
    // Worker initialisieren
    const fibWorker = new Worker(
      new URL('./workers/fibonacci.worker.ts', import.meta.url),
      { type: 'module' }
    );
    
    // Message-Handler für Ergebnisse
    fibWorker.onmessage = (event: MessageEvent<number>) => {
      setResult(event.data);
      setLoading(false);
    };
    
    setWorker(fibWorker);
    
    // Cleanup
    return () => fibWorker.terminate();
  }, []);
  
  const calculate = () => {
    if (!worker) return;
    
    setLoading(true);
    worker.postMessage(45);  // Berechnung in Worker
  };
  
  return (
    <div>
      <button onClick={calculate}>Calculate Fibonacci(45)</button>
      {loading && <p>Calculating...</p>}
      <p>Result: {result}</p>
      
      {/* Animation läuft smooth weiter! */}
      <div className="spinner" />
    </div>
  );
}

Vorteil: Main Thread bleibt frei: - UI bleibt responsiv - Animationen laufen smooth - User kann weiter interagieren - Berechnung läuft parallel

35.3 Worker-Typen: Web, Shared, Service

35.3.1 Web Worker (Standard)

Dedicated Worker für eine spezifische Aufgabe. Jede Worker-Instanz ist isoliert.

// workers/computation.worker.ts
self.addEventListener('message', (event) => {
  const result = heavyComputation(event.data);
  self.postMessage(result);
});

35.3.2 Shared Worker (selten verwendet)

Ein Worker, der von mehreren Tabs/Windows geteilt wird.

// workers/shared.worker.ts
const connections: MessagePort[] = [];

self.addEventListener('connect', (event: MessageEvent) => {
  const port = event.ports[0];
  connections.push(port);
  
  port.addEventListener('message', (e) => {
    // Broadcast an alle Connections
    connections.forEach(p => p.postMessage(e.data));
  });
  
  port.start();
});
// App.tsx
const worker = new SharedWorker(
  new URL('./workers/shared.worker.ts', import.meta.url),
  { type: 'module' }
);

worker.port.start();
worker.port.postMessage('Hello from Tab 1');
worker.port.onmessage = (e) => console.log(e.data);

Use Case: Sync zwischen Tabs (z.B. Chat-App, Real-time Notifications)

35.3.3 Service Worker (separates Konzept)

Service Worker sind für Offline-Funktionalität, Caching und Background-Sync gedacht – nicht für Berechnungen. Laufen auch wenn alle Tabs geschlossen sind.

// service-worker.ts
self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open('v1').then(cache => {
      return cache.addAll(['/index.html', '/app.js']);
    })
  );
});

self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.match(event.request).then(response => {
      return response || fetch(event.request);
    })
  );
});

Use Case: PWA, Offline-Support, Background-Sync – nicht für UI-Berechnungen.

35.4 TypeScript: Typsichere Worker-Kommunikation

Problem: postMessage und onmessage sind any-typed. Lösung: Interfaces für Messages.

// types/worker-messages.ts
export interface WorkerRequest {
  id: string;
  type: 'fibonacci' | 'sort' | 'search';
  payload: any;
}

export interface WorkerResponse {
  id: string;
  result: any;
  error?: string;
}

// Spezifische Message-Typen
export interface FibonacciRequest {
  id: string;
  type: 'fibonacci';
  payload: { n: number };
}

export interface FibonacciResponse {
  id: string;
  result: number;
}

export interface SortRequest {
  id: string;
  type: 'sort';
  payload: { data: number[] };
}

export interface SortResponse {
  id: string;
  result: number[];
}
// workers/computation.worker.ts
import type { WorkerRequest, WorkerResponse } from '../types/worker-messages';

self.addEventListener('message', (event: MessageEvent<WorkerRequest>) => {
  const { id, type, payload } = event.data;
  
  try {
    let result: any;
    
    switch (type) {
      case 'fibonacci':
        result = fibonacci(payload.n);
        break;
      case 'sort':
        result = payload.data.sort((a: number, b: number) => a - b);
        break;
      case 'search':
        result = payload.data.filter((item: any) => 
          item.name.includes(payload.query)
        );
        break;
      default:
        throw new Error(`Unknown operation: ${type}`);
    }
    
    const response: WorkerResponse = { id, result };
    self.postMessage(response);
    
  } catch (error) {
    const response: WorkerResponse = {
      id,
      result: null,
      error: error instanceof Error ? error.message : 'Unknown error'
    };
    self.postMessage(response);
  }
});

function fibonacci(n: number): number {
  if (n <= 1) return n;
  return fibonacci(n - 1) + fibonacci(n - 2);
}

35.5 Custom Hook: useWorker für wiederverwendbare Worker-Logic

// hooks/useWorker.ts
import { useEffect, useRef, useState } from 'react';
import type { WorkerRequest, WorkerResponse } from '../types/worker-messages';

interface UseWorkerOptions {
  workerUrl: string;
}

export function useWorker({ workerUrl }: UseWorkerOptions) {
  const workerRef = useRef<Worker | null>(null);
  const [isReady, setIsReady] = useState(false);
  
  // Pending requests Queue
  const pendingRequests = useRef<Map<string, (result: any, error?: string) => void>>(
    new Map()
  );
  
  useEffect(() => {
    const worker = new Worker(new URL(workerUrl, import.meta.url), { 
      type: 'module' 
    });
    
    worker.onmessage = (event: MessageEvent<WorkerResponse>) => {
      const { id, result, error } = event.data;
      const resolver = pendingRequests.current.get(id);
      
      if (resolver) {
        resolver(result, error);
        pendingRequests.current.delete(id);
      }
    };
    
    worker.onerror = (error) => {
      console.error('Worker error:', error);
    };
    
    workerRef.current = worker;
    setIsReady(true);
    
    return () => {
      worker.terminate();
      pendingRequests.current.clear();
    };
  }, [workerUrl]);
  
  const execute = async <T = any>(
    type: WorkerRequest['type'],
    payload: any
  ): Promise<T> => {
    return new Promise((resolve, reject) => {
      if (!workerRef.current || !isReady) {
        reject(new Error('Worker not ready'));
        return;
      }
      
      const id = crypto.randomUUID();
      const request: WorkerRequest = { id, type, payload };
      
      pendingRequests.current.set(id, (result, error) => {
        if (error) {
          reject(new Error(error));
        } else {
          resolve(result as T);
        }
      });
      
      workerRef.current.postMessage(request);
    });
  };
  
  return { execute, isReady };
}
// Dashboard.tsx - Verwendung
import { useState } from 'react';
import { useWorker } from './hooks/useWorker';

function Dashboard() {
  const [result, setResult] = useState<number | null>(null);
  const [loading, setLoading] = useState(false);
  const { execute, isReady } = useWorker({ 
    workerUrl: './workers/computation.worker.ts' 
  });
  
  const calculate = async () => {
    setLoading(true);
    
    try {
      const result = await execute<number>('fibonacci', { n: 45 });
      setResult(result);
    } catch (error) {
      console.error('Calculation failed:', error);
    } finally {
      setLoading(false);
    }
  };
  
  return (
    <div>
      <button onClick={calculate} disabled={!isReady || loading}>
        Calculate Fibonacci(45)
      </button>
      {loading && <p>Calculating...</p>}
      {result !== null && <p>Result: {result}</p>}
    </div>
  );
}

35.6 Praxis-Beispiel 1: Large Dataset Filtering

Reales Problem: 100.000 Produkte durchsuchen – blockiert UI ohne Worker.

// workers/search.worker.ts
interface Product {
  id: number;
  name: string;
  category: string;
  price: number;
}

interface SearchRequest {
  id: string;
  type: 'search';
  payload: {
    products: Product[];
    query: string;
  };
}

self.addEventListener('message', (event: MessageEvent<SearchRequest>) => {
  const { id, payload } = event.data;
  const { products, query } = payload;
  
  const start = performance.now();
  
  const results = products.filter(product => 
    product.name.toLowerCase().includes(query.toLowerCase()) ||
    product.category.toLowerCase().includes(query.toLowerCase())
  );
  
  const duration = performance.now() - start;
  
  self.postMessage({ 
    id, 
    result: { results, duration } 
  });
});
// ProductSearch.tsx
import { useState, useCallback } from 'react';
import { useWorker } from './hooks/useWorker';

interface Product {
  id: number;
  name: string;
  category: string;
  price: number;
}

function ProductSearch({ products }: { products: Product[] }) {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState<Product[]>([]);
  const [searchTime, setSearchTime] = useState<number>(0);
  const { execute } = useWorker({ workerUrl: './workers/search.worker.ts' });
  
  const handleSearch = useCallback(async (searchQuery: string) => {
    if (!searchQuery.trim()) {
      setResults([]);
      return;
    }
    
    const { results, duration } = await execute<{ results: Product[]; duration: number }>(
      'search',
      { products, query: searchQuery }
    );
    
    setResults(results);
    setSearchTime(duration);
  }, [products, execute]);
  
  // Debounced search
  useEffect(() => {
    const timer = setTimeout(() => {
      handleSearch(query);
    }, 300);
    
    return () => clearTimeout(timer);
  }, [query, handleSearch]);
  
  return (
    <div>
      <input
        type="search"
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="Search products..."
      />
      
      <p>Found {results.length} products in {searchTime.toFixed(2)}ms</p>
      
      <ul>
        {results.map(product => (
          <li key={product.id}>
            {product.name} - ${product.price}
          </li>
        ))}
      </ul>
    </div>
  );
}

35.7 Praxis-Beispiel 2: Image Processing

Bildverarbeitung ist CPU-intensiv – perfekt für Workers.

// workers/image.worker.ts
interface ImageProcessRequest {
  id: string;
  type: 'grayscale' | 'blur' | 'brighten';
  payload: {
    imageData: ImageData;
    intensity?: number;
  };
}

self.addEventListener('message', (event: MessageEvent<ImageProcessRequest>) => {
  const { id, type, payload } = event.data;
  const { imageData, intensity = 1 } = payload;
  
  const processed = processImage(imageData, type, intensity);
  
  self.postMessage({ id, result: processed }, [processed.data.buffer]);
});

function processImage(
  imageData: ImageData, 
  type: string, 
  intensity: number
): ImageData {
  const data = imageData.data;
  const result = new ImageData(
    new Uint8ClampedArray(data),
    imageData.width,
    imageData.height
  );
  
  for (let i = 0; i < data.length; i += 4) {
    const r = data[i];
    const g = data[i + 1];
    const b = data[i + 2];
    
    switch (type) {
      case 'grayscale': {
        const gray = 0.299 * r + 0.587 * g + 0.114 * b;
        result.data[i] = gray;
        result.data[i + 1] = gray;
        result.data[i + 2] = gray;
        break;
      }
      
      case 'brighten': {
        result.data[i] = Math.min(255, r * intensity);
        result.data[i + 1] = Math.min(255, g * intensity);
        result.data[i + 2] = Math.min(255, b * intensity);
        break;
      }
      
      // ... andere Filter
    }
    
    result.data[i + 3] = data[i + 3]; // Alpha beibehalten
  }
  
  return result;
}
// ImageEditor.tsx
import { useRef, useState } from 'react';
import { useWorker } from './hooks/useWorker';

function ImageEditor() {
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const [processing, setProcessing] = useState(false);
  const { execute } = useWorker({ workerUrl: './workers/image.worker.ts' });
  
  const applyFilter = async (filter: 'grayscale' | 'blur' | 'brighten') => {
    const canvas = canvasRef.current;
    if (!canvas) return;
    
    const ctx = canvas.getContext('2d');
    if (!ctx) return;
    
    setProcessing(true);
    
    const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
    
    try {
      const processed = await execute<ImageData>(filter, { 
        imageData,
        intensity: 1.5 
      });
      
      ctx.putImageData(processed, 0, 0);
    } finally {
      setProcessing(false);
    }
  };
  
  return (
    <div>
      <canvas ref={canvasRef} width={800} height={600} />
      
      <div>
        <button onClick={() => applyFilter('grayscale')} disabled={processing}>
          Grayscale
        </button>
        <button onClick={() => applyFilter('brighten')} disabled={processing}>
          Brighten
        </button>
      </div>
      
      {processing && <p>Processing image...</p>}
    </div>
  );
}

Wichtig: postMessage mit Transferable Objects (zweiter Parameter) für große Daten:

self.postMessage(
  { id, result: processed },
  [processed.data.buffer]  // Transfer ownership statt copy
);

Transferable Objects werden zwischen Threads verschoben statt kopiert – massiver Performance-Gewinn bei großen Arrays.

35.8 Vite-Integration: Worker als Module

Vite unterstützt Workers out-of-the-box mit spezieller Syntax:

// ✓ Vite-Syntax für Worker-Import
const worker = new Worker(
  new URL('./worker.ts', import.meta.url),
  { type: 'module' }  // ESM-Worker
);

Warum new URL(..., import.meta.url)?

Vite braucht statische Imports zur Build-Zeit. new URL mit import.meta.url ermöglicht es Vite, den Worker-Code zu bundlen.

// ❌ Falsch: String-Pfad funktioniert nicht
const worker = new Worker('./worker.ts');

// ❌ Falsch: Variable Path funktioniert nicht
const workerPath = './worker.ts';
const worker = new Worker(workerPath);

// ✓ Richtig: URL-Constructor mit import.meta.url
const worker = new Worker(
  new URL('./worker.ts', import.meta.url),
  { type: 'module' }
);

35.8.1 Vite Worker Plugin (Optional)

Für komplexere Setups:

npm install vite-plugin-worker
// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import worker from 'vite-plugin-worker';

export default defineConfig({
  plugins: [
    react(),
    worker()
  ],
  worker: {
    format: 'es',  // ESM-Format
  }
});

Comlink macht Worker-Kommunikation einfacher – wie normale Function Calls.

npm install comlink
// workers/api.worker.ts
import { expose } from 'comlink';

const api = {
  async fibonacci(n: number): Promise<number> {
    if (n <= 1) return n;
    return (await fibonacci(n - 1)) + (await fibonacci(n - 2));
  },
  
  async searchProducts(products: Product[], query: string): Promise<Product[]> {
    return products.filter(p => 
      p.name.toLowerCase().includes(query.toLowerCase())
    );
  },
  
  async processImage(imageData: ImageData, filter: string): Promise<ImageData> {
    // ... processing logic
    return processedImageData;
  }
};

expose(api);

function fibonacci(n: number): number {
  if (n <= 1) return n;
  return fibonacci(n - 1) + fibonacci(n - 2);
}
// hooks/useComlinkWorker.ts
import { useEffect, useState } from 'react';
import { wrap, Remote } from 'comlink';

export function useComlinkWorker<T>(workerUrl: string) {
  const [api, setApi] = useState<Remote<T> | null>(null);
  
  useEffect(() => {
    const worker = new Worker(new URL(workerUrl, import.meta.url), {
      type: 'module'
    });
    
    const wrappedApi = wrap<T>(worker);
    setApi(wrappedApi);
    
    return () => worker.terminate();
  }, [workerUrl]);
  
  return api;
}
// App.tsx - Verwendung
import { useComlinkWorker } from './hooks/useComlinkWorker';

interface WorkerApi {
  fibonacci(n: number): Promise<number>;
  searchProducts(products: Product[], query: string): Promise<Product[]>;
}

function App() {
  const [result, setResult] = useState<number>(0);
  const api = useComlinkWorker<WorkerApi>('./workers/api.worker.ts');
  
  const calculate = async () => {
    if (!api) return;
    
    // Sieht aus wie normaler Function Call!
    const result = await api.fibonacci(45);
    setResult(result);
  };
  
  return (
    <div>
      <button onClick={calculate}>Calculate</button>
      <p>Result: {result}</p>
    </div>
  );
}

Comlink-Vorteile: - Keine Message-IDs managen - TypeScript-Types bleiben erhalten - Error-Handling automatisch - Cleaner Code

35.10 Worker-Limitierungen: Was Workers NICHT können

Workers haben keinen Zugriff auf:

Feature Verfügbar? Grund
DOM ❌ Nein Kein document, window
React Components ❌ Nein Braucht DOM
LocalStorage ❌ Nein Synchronous API
Cookies ❌ Nein Security
Fetch API ✅ Ja Async, isolated
IndexedDB ✅ Ja Async, isolated
WebSockets ✅ Ja Async, isolated
console.log ✅ Ja Logging OK
setTimeout ✅ Ja Async OK
crypto ✅ Ja Krypto-Funktionen OK
// ❌ Falsch: DOM-Zugriff im Worker
self.addEventListener('message', () => {
  const div = document.createElement('div');  // Error: document is not defined
  const value = localStorage.getItem('key');  // Error: localStorage is not defined
});

// ✓ Richtig: Fetch, Crypto, Timer
self.addEventListener('message', async () => {
  const response = await fetch('/api/data');  // OK
  const data = await response.json();
  
  const hash = await crypto.subtle.digest('SHA-256', data);  // OK
  
  setTimeout(() => {
    self.postMessage('Done');
  }, 1000);  // OK
});

35.11 Debugging: Worker-Code debuggen

Browser DevTools unterstützen Worker-Debugging:

Chrome DevTools: 1. Sources Tab → Worker-Threads links 2. Breakpoints in Worker-Code setzen 3. Console.log aus Worker erscheint in Main Console

// workers/debug.worker.ts
self.addEventListener('message', (event) => {
  console.log('Worker received:', event.data);  // Sichtbar in DevTools
  
  debugger;  // Breakpoint funktioniert!
  
  const result = process(event.data);
  
  console.log('Worker sending:', result);
  self.postMessage(result);
});

Performance-Profiling:

self.addEventListener('message', (event) => {
  console.time('Worker processing');
  
  const result = heavyComputation(event.data);
  
  console.timeEnd('Worker processing');
  self.postMessage(result);
});

35.12 Häufige Fehler

Fehler 1: Shared State zwischen Main und Worker

// ❌ Falsch: Objekte werden kopiert, nicht geteilt
const data = { count: 0 };

worker.postMessage(data);
data.count = 10;  // Worker sieht das NICHT!

// Worker empfängt Copy mit count: 0

Workers teilen keinen Memory. postMessage kopiert Daten (Structured Clone).

Fehler 2: Funktionen in postMessage

// ❌ Falsch: Funktionen können nicht serialisiert werden
worker.postMessage({
  data: [1, 2, 3],
  callback: (result) => console.log(result)  // Error!
});

// ✓ Richtig: Nur Daten, Logic im Worker
worker.postMessage({ data: [1, 2, 3] });

worker.onmessage = (event) => {
  console.log(event.data);  // Callback im Main Thread
};

Fehler 3: Worker nicht terminieren

// ❌ Falsch: Worker läuft ewig
function Component() {
  const worker = new Worker(...);
  
  // Worker wird nie gestoppt!
  
  return <div>...</div>;
}

// ✓ Richtig: Cleanup in useEffect
function Component() {
  useEffect(() => {
    const worker = new Worker(...);
    
    return () => worker.terminate();  // Cleanup!
  }, []);
}

Fehler 4: Synchrone Antwort erwarten

// ❌ Falsch: postMessage ist async!
worker.postMessage({ data: 42 });
const result = worker.result;  // Gibt's nicht!

// ✓ Richtig: onmessage-Handler oder Promise
worker.postMessage({ data: 42 });
worker.onmessage = (event) => {
  const result = event.data;
};

// Oder mit Promise-Wrapper (wie useWorker Hook)

Fehler 5: Worker-Path relativ zu HTML

// ❌ Falsch: Relativ zu index.html (funktioniert nicht nach Build)
const worker = new Worker('/src/workers/compute.worker.ts');

// ✓ Richtig: import.meta.url für Build-Tool
const worker = new Worker(
  new URL('./workers/compute.worker.ts', import.meta.url),
  { type: 'module' }
);

35.13 Performance-Überlegungen

Wann lohnt sich ein Worker?

Task Duration Worker? Grund
Array.sort() 100 items <1ms ❌ Nein Overhead > Gewinn
JSON.parse() 1MB ~10ms ⚠️ Maybe Grenzfall
Fibonacci(45) ~5000ms ✅ Ja Blockiert UI lange
Image Processing ~100ms+ ✅ Ja CPU-intensiv
Search 100k items ~50ms+ ✅ Ja Hängt von UX ab

Overhead einkalkulieren: - Worker-Startup: ~50-100ms - Message-Serialisierung: ~1-10ms - Context-Switch: ~1ms

Wenn Task <50ms dauert, ist Worker oft langsamer als direkter Code.

Benchmark-Pattern:

// Vergleiche mit und ohne Worker
function benchmark() {
  // Ohne Worker
  console.time('Main Thread');
  const result1 = fibonacci(40);
  console.timeEnd('Main Thread');
  
  // Mit Worker
  console.time('Worker Thread');
  worker.postMessage(40);
  worker.onmessage = () => {
    console.timeEnd('Worker Thread');
  };
}

35.14 WebAssembly in Workers (Ausblick)

Für maximale Performance: WebAssembly im Worker.

// workers/wasm.worker.ts
self.addEventListener('message', async (event) => {
  // WASM-Module laden
  const response = await fetch('/compute.wasm');
  const buffer = await response.arrayBuffer();
  const module = await WebAssembly.instantiate(buffer);
  
  // WASM-Function aufrufen
  const result = module.instance.exports.fibonacci(event.data);
  
  self.postMessage(result);
});

WebAssembly ist 10-100x schneller als JavaScript für CPU-intensive Tasks – kombiniert mit Workers ideal für: - Videokodierung - Krypto-Berechnungen - Wissenschaftliche Simulationen - Game-Engines

Web Workers sind kein Nice-to-Have für Performance-kritische Apps – sie sind essentiell. Der Main Thread ist zu wertvoll, um ihn mit Berechnungen zu blockieren. Workers ermöglichen echte Parallelität im Browser – nutze sie für CPU-intensive Tasks, und die UI bleibt smooth.