Web Workers für rechenintensive Aufgaben verstehen
JavaScript läuft auf einem einzigen Thread, was bedeutet, dass eine rechenintensive Aufgabe – ein riesiges JSON-Objekt parsen, ein Bild skalieren, einen Pfadfindungsalgorithmus ausführen – alles andere auf der Seite blockiert, solange sie läuft: kein Scrollen, keine Klicks, keine Animationen. Web Workers beheben das, indem sie dir einen echten Hintergrund-Thread geben, und sie werden seit über einem Jahrzehnt in jedem relevanten Browser unterstützt.
Warum der Hauptthread erstickt
Der Hauptthread des Browsers erledigt doppelte Arbeit: Er führt dein JavaScript aus und kümmert sich um Rendering, Layout und Eingabe-Events. Braucht eine for-Schleife 800 ms bis zum Ende, ist die Seite für diese gesamten 800 ms eingefroren – keine Ausnahmen, kein „mach es einfach async". Auch async/await und Promises helfen hier nicht; sie planen, wann Code relativ zu anderem JavaScript läuft, verlagern aber keine Arbeit vom Thread. Eine synchrone Schleife ist synchron, egal wie viele awaits sie umgeben.
// This blocks the main thread completely, Promise or not
async function findPrimes(limit) {
const primes = [];
for (let n = 2; n <= limit; n++) {
if (isPrime(n)) primes.push(n);
}
return primes;
}
Arbeit in einen Worker verlagern
Ein Web Worker läuft in seinem eigenen globalen Kontext ohne Zugriff auf das DOM, window oder die Variablen deiner Seite. Die Kommunikation erfolgt ausschließlich über Nachrichtenaustausch.
// worker.js
self.onmessage = (event) => {
const { limit } = event.data;
const primes = [];
for (let n = 2; n <= limit; n++) {
if (isPrime(n)) primes.push(n);
}
self.postMessage({ primes });
};
function isPrime(n) {
if (n < 2) return false;
for (let i = 2; i * i <= n; i++) {
if (n % i === 0) return false;
}
return true;
}
// main.js
const worker = new Worker('worker.js');
worker.postMessage({ limit: 1_000_000 });
worker.onmessage = (event) => {
console.log(`Found ${event.data.primes.length} primes`);
};
Die Seite bleibt vollständig reaktionsfähig, während worker.js sich durch eine Million Zahlen arbeitet, weil diese Schleife jetzt komplett auf einem separaten OS-Thread läuft.
Einen Worker in ein Promise wickeln
Rohes onmessage/postMessage wird unhandlich, sobald man Anfragen mit Antworten korrelieren muss. Ein kleiner Wrapper lässt Worker wie jede andere asynchrone API wirken:
function runInWorker(worker, payload) {
return new Promise((resolve, reject) => {
const handleMessage = (event) => {
cleanup();
resolve(event.data);
};
const handleError = (error) => {
cleanup();
reject(error);
};
const cleanup = () => {
worker.removeEventListener('message', handleMessage);
worker.removeEventListener('error', handleError);
};
worker.addEventListener('message', handleMessage);
worker.addEventListener('error', handleError);
worker.postMessage(payload);
});
}
const result = await runInWorker(worker, { limit: 1_000_000 });
console.log(result.primes.length);
Diese Version behandelt nur eine laufende Anfrage gleichzeitig – für viele Anwendungsfälle in Ordnung, aber wenn man mehrere nebenläufige Anfragen an denselben Worker abfeuern muss, versieht man jede Nachricht mit einer ID und ordnet Antworten per ID zu, statt sich auf ein einzelnes ausstehendes Promise zu verlassen.
Was tatsächlich kopiert und was übertragen wird
Standardmäßig klont postMessage die gesendeten Daten mit dem Structured-Clone-Algorithmus – Objekte, Arrays, Maps, Dates und Typed Arrays überstehen die Reise, Funktionen, DOM-Knoten und Klasseninstanzen mit Methoden dagegen nicht. Bei großen binären Nutzlasten (ein großer ArrayBuffer aus Bild- oder Audioverarbeitung) ist Klonen verschwenderisch – man kopiert Megabytes über Threadgrenzen.
Transferable Objects vermeiden die Kopie ganz, indem sie das Eigentum an die andere Seite übergeben:
const buffer = new ArrayBuffer(64 * 1024 * 1024); // 64MB
worker.postMessage({ buffer }, [buffer]);
// `buffer` is now unusable in this thread — ownership moved to the worker
Nach einer Übertragung ist buffer.byteLength auf der sendenden Seite 0. Das ist eine echte Eigentumsübergabe, kein geteilter Verweis – nur eine Seite kann es zu einem Zeitpunkt nutzen, und genau deshalb ist es schnell: Es ist keine Kopie nötig.
Wann ein Worker das falsche Werkzeug ist
Worker haben echten Overhead: Einen zu starten kostet ein paar Millisekunden, und jede Nachricht über die Grenze läuft durch Structured Cloning (sofern nicht übertragen). Bei Arbeit, die in ein paar Millisekunden fertig ist, kann dieser Overhead die Einsparung übersteigen. Worker rechnen sich für:
- Parsen oder Transformieren großer Datensätze (CSV, JSON, Binärformate)
- Bild-/Video-/Audioverarbeitung (Skalieren, Filter, Kodierung)
- Komplexe, CPU-gebundene Algorithmen (Pfadfindung, Physiksimulation, Kryptografie)
- Alles, was sonst sichtbares Ruckeln bei Interaktion verursachen würde
Sie sind das falsche Werkzeug für alles mit DOM-Bezug (sie können es nicht anfassen), alles, was bei winzigen Eingaben niedrige Latenz braucht, oder Arbeit, die bereits I/O-gebunden ist (ein fetch-Aufruf blockiert den Hauptthread ohnehin nicht – er ist bereits asynchron).
Fazit
Der Hauptthread ist eine zwischen deinem Code und der Fähigkeit des Browsers, zu rendern und auf Eingaben zu reagieren, geteilte Ressource – eine lange synchrone Aufgabe hungert beides aus. Web Workers geben rechenintensiver Arbeit einen tatsächlich separaten Thread statt nur einen anderen Platz in der Warteschlange der Event-Loop, und das ist das eine, was async/await allein nie leisten kann.