Web Workers: Schwere Berechnungen vom Main Thread auslagern
60fps
ms
Performance · Web Workers · JavaScript · Magento 2
Web Workers: Schwere Berechnungen vom Main Thread auslagern
Wann sich Multithreading in JavaScript wirklich lohnt

Web Worker verlagern rechenintensive JavaScript-Aufgaben wie das Filtern und Sortieren großer Produktlisten in einen eigenen Thread, ohne den Main Thread zu blockieren. Dieser Artikel erklärt, wie Message Passing und Structured Clone funktionieren, wann Transferable Objects den Unterschied machen, wie Comlink die API vereinfacht und wo die praktischen Grenzen von Web Workern liegen.

14 Min. Lesezeit postMessage · Structured Clone · Comlink Main Thread · Hyvä Theme · Alpine.js

1. Wann der Main Thread bei CPU-intensiven Aufgaben blockiert

JavaScript läuft im Browser standardmäßig auf einem einzigen Thread, der sich Skriptausführung, Style-Berechnung, Layout, Paint und Nutzerinteraktionen teilt. Jede Aufgabe, die länger als etwa 50 Millisekunden dauert, gilt gemäß der Long Tasks API als „lange Aufgabe“ und blockiert in dieser Zeit spürbar alles andere: Klicks werden erst verzögert verarbeitet, Scroll-Events stottern, Animationen frieren ein. Konkrete Größenordnungen aus der Praxis: Das Sortieren eines Arrays mit 10.000 Objekten über einen komplexen Comparator kann synchron 150 bis 300 Millisekunden dauern, JSON.parse() einer Antwort über 1 MB liegt oft bei 30 bis 60 Millisekunden, und Pixelmanipulation auf einem Canvas kann je nach Bildgröße mehrere hundert Millisekunden beanspruchen.

Die direkte Folge ist ein messbar schlechterer INP-Wert (Interaction to Next Paint), weil der Browser erst nach Abschluss der laufenden Aufgabe auf die nächste Nutzereingabe reagieren kann. Der PerformanceObserver mit entryTypes: ['longtask'] macht solche Blockaden im Entwicklungsprozess sichtbar, noch bevor sie im Feld als Jank auffallen. Genau hier setzen Web Workers an: Sie verschieben reine Berechnungslogik, die keinen DOM-Zugriff benötigt, in einen separaten Thread und halten den Main Thread frei für Rendering und Interaktion.

2. Wie Web Workers funktionieren: eigener Thread, kein DOM-Zugriff

Ein Dedicated Worker läuft in einem eigenen DedicatedWorkerGlobalScope und hat keinen Zugriff auf window, document oder irgendeine DOM-API. Er hat jedoch Zugriff auf fetch, IndexedDB, WebSocket, Timer-Funktionen und kann über importScripts() oder als ES-Modul weitere Skripte nachladen. Speicher wird standardmäßig nicht geteilt: Hauptthread und Worker sind zwei getrennte Adressräume, die ausschließlich über Nachrichten kommunizieren, es sei denn, man setzt explizit SharedArrayBuffer mit Atomics ein, was zusätzliche Cross-Origin-Isolation-Header erfordert.

Das Instanziieren eines Workers über new Worker(url) kostet Zeit: Das Skript muss vom Netzwerk oder Cache geladen, geparst und ausgeführt werden, zusätzlich erzeugt der Browser einen neuen OS-Thread. Für kleine, schlanke Worker-Skripte liegt dieser Startup meist im Bereich von 3 bis 15 Millisekunden, bei größeren Bundles mit schweren Top-Level-Importen kann er auf mehrere Dutzend Millisekunden steigen. Diese Kosten fallen nur einmal bei der Erstellung an, nicht bei jeder Nachricht, weshalb sich Worker-Wiederverwendung strukturell lohnt.


<!-- Hyvä phtml: Worker als ES-Modul instanziieren, CSP-konform ohne Inline-Skript -->
<script type="module">
  const productWorker = new Worker(
    "<?= $block->escapeJs($block->getViewFileUrl('Mironsoft_Catalog/js/product-filter.worker.js')) ?>",
    { type: 'module', name: 'product-filter' }
  );

  // worker-src Direktive muss den eigenen Origin erlauben, damit der Browser
  // den Worker unter der Content Security Policy des Shops laden darf
  window.addEventListener('beforeunload', () => productWorker.terminate());
</script>

3. Message Passing: postMessage und die Kosten des Structured Clone

Kommunikation zwischen Main Thread und Worker läuft über postMessage() und den message-Event-Handler. Standardmäßig verwendet der Browser dafür den Structured Clone Algorithm: Er kopiert Daten tief, inklusive verschachtelter Objekte, Arrays, Map, Set und Date, kann aber keine Funktionen, DOM-Knoten oder Klasseninstanzen mit Prototyp-Kette klonen. Die Kosten skalieren nicht nur mit der Byte-Größe, sondern vor allem mit der strukturellen Komplexität: Ein flaches Array mit 10.000 Zahlen lässt sich in ein bis zwei Millisekunden klonen, ein tief verschachteltes Array mit 10.000 komplexen Produktobjekten kann dagegen 20 bis 40 Millisekunden pro Richtung kosten, also für Hin- und Rückweg zusammen durchaus 50 bis 80 Millisekunden.

Diese Kosten sind der Grund, warum Web Worker keine Wunderwaffe sind, sondern ein Werkzeug mit eigenem Overhead: Die Ersparnis auf dem Main Thread muss größer sein als die Summe aus Serialisierungskosten für Hin- und Rückweg plus eventuellem Worker-Startup. Bei chatty Kommunikationsmustern mit vielen kleinen Nachrichten addiert sich außerdem der Event-Loop-Overhead jeder einzelnen postMessage-Runde, weshalb sich in der Praxis wenige, große Nachrichten fast immer besser schlagen als viele kleine.


// main.js: eine Nachricht mit vollständigem Datensatz statt vieler kleiner Aufrufe senden
const worker = new Worker('/js/product-filter.worker.js');

worker.postMessage({
  type: 'SORT_PRODUCTS',
  products: productList,          // structured clone kopiert das komplette Array
  sortKey: 'price',
  direction: 'asc'
});

worker.onmessage = (event) => {
  const { type, sortedIds } = event.data;
  if (type === 'SORT_RESULT') {
    applySortOrder(sortedIds);    // nur eine ID-Liste zurückschicken, nicht die vollen Objekte
  }
};

worker.onerror = (error) => {
  console.error('Worker-Fehler:', error.message, error.filename, error.lineno);
};

4. Transferable Objects: Ownership übertragen statt kopieren

Für bestimmte Typen bietet die Web-Platform eine deutlich günstigere Alternative zum Structured Clone: Transferable Objects wie ArrayBuffer, MessagePort, ImageBitmap und OffscreenCanvas werden nicht kopiert, sondern deren Ownership wird an den Empfänger übertragen. Technisch verschiebt der Browser dabei lediglich einen Speicher-Pointer, unabhängig von der Datengröße, weshalb der Transfer eines 50-MB-ArrayBuffer unter einer Millisekunde dauert, während ein vollständiger Structured Clone derselben Datenmenge mehrere Dutzend Millisekunden kosten würde.

Der Preis dafür: Nach dem Transfer ist der ursprüngliche Buffer im sendenden Kontext „neutered“, also entwertet, seine byteLength wird zu 0 und jeder weitere Zugriff schlägt fehl. Für numerische Massendaten wie Produktpreise, Lagerbestände oder IDs lohnt sich deshalb der Umweg über typisierte Arrays: Statt eines Arrays aus JavaScript-Objekten überträgt man einen Float64Array oder Uint32Array, dessen zugrundeliegender ArrayBuffer im zweiten Argument von postMessage() explizit als Transfer-Liste übergeben wird.


// main.js: Preise als typisiertes Array per Transfer statt per Clone senden
const prices = new Float64Array(productCount);
products.forEach((product, index) => { prices[index] = product.price; });

worker.postMessage(
  { type: 'RANK_BY_PRICE', prices },
  [prices.buffer]   // Ownership des ArrayBuffer wird übertragen, kein Kopieren
);

// Nach dem Transfer ist "prices" im Main Thread entwertet:
console.log(prices.buffer.byteLength); // 0, der Buffer gehört jetzt dem Worker

// worker.js: das übertragene Buffer direkt weiterverwenden
self.onmessage = (event) => {
  const { prices } = event.data;         // gleicher Speicher, keine Kopie
  const rankedIndexes = rankByValue(prices);
  self.postMessage({ type: 'RANK_RESULT', rankedIndexes });
};

5. Praxisbeispiel: Produktlisten-Filterung im Hyvä-Storefront

Ein typisches Szenario in einem Hyvä-Shop: Eine Kategorieseite lädt mehrere tausend Artikel client-seitig vor, damit Facetten-Filter über Alpine.js ohne Server-Roundtrip sofort reagieren. Werden Filtern und Sortieren dieser Liste direkt in einer Alpine-x-data-Methode synchron ausgeführt, blockiert jeder Klick auf einen Filter-Chip den Main Thread für die Dauer der Berechnung. Bei 5.000 Artikeln und einem Mehrfach-Sortierkriterium (Relevanz, dann Preis, dann Verfügbarkeit) liegt diese Dauer auf einem mittelklassigen Mobilgerät realistisch bei 80 bis 150 Millisekunden, spürbar über der 50-Millisekunden-Schwelle für lange Aufgaben.

Die Lösung: Der Main Thread schickt die Produktdaten als typisierte Arrays (IDs, Preise als Float64Array) plus die Filterkriterien an einen Worker, der Filterung und Sortierung übernimmt und ausschließlich eine sortierte Index-Liste zurückschickt, keine vollen Produktobjekte. Dieser Rückweg ist klein und günstig zu klonen, während die eigentliche Rechenlast vollständig außerhalb des Main Threads liegt. Der Klick-Handler selbst kehrt dadurch fast sofort zurück, der Browser kann zwischen Anfrage und Antwort weiter Frames rendern, und sobald die Indexliste eintrifft, aktualisiert Alpine reaktiv nur die DOM-Reihenfolge der bereits vorhandenen Karten.

Wichtig ist, dass ausschließlich reine, DOM-freie Logik im Worker landet: Vergleichsfunktionen, Preisberechnungen, Textsuche über Produktnamen. Das Rendering der Produktkarten selbst bleibt zwingend auf dem Main Thread, weil Worker keinen DOM-Zugriff haben, dazu mehr in Abschnitt 8.


// product-filter.worker.js: reine Berechnungslogik ohne DOM-Zugriff
self.onmessage = (event) => {
  const { ids, prices, filterCriteria, sortKey, direction } = event.data;

  let matchingIndexes = [];
  for (let i = 0; i < ids.length; i++) {
    if (matchesFilters(i, prices, filterCriteria)) {
      matchingIndexes.push(i);
    }
  }

  matchingIndexes.sort((a, b) => {
    const diff = prices[a] - prices[b];
    return direction === 'asc' ? diff : -diff;
  });

  const sortedIds = matchingIndexes.map((i) => ids[i]);

  // nur die schmale ID-Liste zurücksenden, nicht die vollen Produktobjekte
  self.postMessage({ type: 'FILTER_RESULT', sortedIds });
};

function matchesFilters(index, prices, criteria) {
  if (criteria.maxPrice && prices[index] > criteria.maxPrice) return false;
  return true;
}

Manuelles postMessage/onmessage-Handling wird bei mehreren parallelen Operationen schnell unübersichtlich, weil man Nachrichten-IDs, Promise-Auflösung und Fehlerbehandlung selbst verdrahten muss. Comlink von Google Chrome Labs löst das über einen Proxy-basierten RPC-Layer: Worker-Funktionen lassen sich damit wie normale asynchrone Funktionen aufrufen, worker.sortProducts(list) liefert intern ein Promise, das über postMessage aufgelöst wird, ohne dass man den Nachrichtenaustausch von Hand schreibt.

Der Komfort hat einen kleinen, aber messbaren Preis: Jeder Proxy-Aufruf fügt eine dünne Schicht aus Reflection und Promise-Verwaltung hinzu, in der Praxis meist unter einer Millisekunde zusätzlicher Overhead pro Aufruf, kaum relevant gegenüber den Kosten des eigentlichen Structured Clone. Wichtig: Comlink ersetzt den Structured Clone Algorithm nicht, für Transferable Objects muss weiterhin explizit Comlink.transfer(data, [buffer]) verwendet werden, sonst greift automatisch der teurere Clone-Pfad. Für ein einzelnes, simples Worker-Interface lohnt sich die zusätzliche Abhängigkeit selten, bei mehreren Methoden und komplexeren Rückgabewerten reduziert Comlink jedoch spürbar Boilerplate-Code.


// worker.js: Funktionen direkt exponieren statt manuelles onmessage-Handling
import * as Comlink from 'comlink';

const api = {
  sortProducts(products, sortKey) {
    return [...products].sort((a, b) => a[sortKey] - b[sortKey]);
  },
  filterByPrice(products, maxPrice) {
    return products.filter((product) => product.price <= maxPrice);
  }
};

Comlink.expose(api);

// main.js: Worker-Methoden wie normale async Funktionen aufrufen
import * as Comlink from 'comlink';

const worker = new Worker('/js/catalog.worker.js', { type: 'module' });
const api = Comlink.wrap(worker);

const sorted = await api.sortProducts(productList, 'price');
renderProductGrid(sorted);

7. Worker-Lifecycle: Startup-Kosten, Pooling und Wiederverwendung

Weil jede new Worker()-Instanziierung Netzwerk- oder Cache-Zugriff, Parsing und Thread-Erzeugung kostet, ist es ineffizient, für jede einzelne Filteroperation einen neuen Worker zu erzeugen. Sinnvoller ist es, einen Worker einmal beim Betreten einer Kategorieseite zu instanziieren, ihn für die gesamte Lebensdauer der Seite offen zu halten und für jede Filter- oder Sortieraktion wiederzuverwenden. Erst beim Verlassen der Seite oder bei echter Inaktivität wird worker.terminate() aufgerufen, um den zugehörigen Thread und dessen Speicher freizugeben.

Für Workloads, die tatsächlich parallel auf mehreren Kernen laufen sollen, etwa das gleichzeitige Berechnen mehrerer unabhängiger Facetten-Aggregationen, bietet sich ein Worker-Pool an: Eine feste Anzahl an Workern, typischerweise navigator.hardwareConcurrency - 1, um einen Kern für den Main Thread und Rendering frei zu lassen, verteilt Aufgaben Round-Robin. Zu viele gleichzeitige Worker erzeugen dagegen Kontextwechsel-Overhead und zusätzlichen Speicherdruck, besonders auf Mobilgeräten mit begrenztem RAM, weshalb ein Pool fast immer besser skaliert als unbegrenztes Worker-Spawning pro Aktion.

8. Grenzen von Web Workers: kein DOM-Zugriff, Serialisierung, wann nicht

Die wichtigste Einschränkung bleibt der fehlende DOM-Zugriff: Ein Worker kann keine Elemente lesen oder schreiben, keine Events direkt binden und keine getComputedStyle-Aufrufe machen. Ausnahme ist OffscreenCanvas, das Canvas-Rendering tatsächlich im Worker erlaubt, für klassisches DOM-Update-Rendering wie Alpine-Reaktivität gilt die Einschränkung jedoch uneingeschränkt. Ergebnisse müssen also immer zurück auf den Main Thread, wo sie angewendet werden.

Genauso wichtig: Nicht jede Aufgabe profitiert von einem Worker. Wenn eine Berechnung ohnehin nur wenige Millisekunden dauert, etwa die Validierung eines einzelnen Formularfelds oder das Filtern einer Liste mit 50 Einträgen, übersteigt der Overhead aus Worker-Startup und Message-Roundtrip häufig die eingesparte Zeit auf dem Main Thread. Eine praktikable Faustregel: Erst ab Aufgaben, die synchron voraussichtlich über 50 Millisekunden beanspruchen würden, lohnt sich die Auslagerung überhaupt. Darunter bleibt der reine Overhead der Kommunikation, der bei kleinen Nachrichten meist zwischen 1 und 5 Millisekunden liegt, das dominierende Kostenelement, und ein Worker macht die Interaktion in Summe sogar langsamer statt schneller.

9. Main Thread vs. Web Worker im direkten Vergleich

Nicht jede rechenintensive Aufgabe gehört automatisch in einen Worker, und nicht jede kleine Aufgabe sollte auf dem Main Thread bleiben. Die folgende Übersicht zeigt anhand typischer Aufgaben in einem Magento-/Hyvä-Storefront, wann sich die Auslagerung lohnt und wann der Overhead überwiegt.

Aufgabe Main Thread (synchron) Mit Web Worker Empfehlung
Sortieren, 5.000 Produkte ~80-150 ms blockierend < 5 ms Main-Thread-Anteil In Worker auslagern
JSON parsen, > 1 MB ~30-60 ms blockierend Parsing im Worker In Worker auslagern
Canvas-Pixelmanipulation Mehrere hundert ms OffscreenCanvas im Worker In Worker auslagern
Einzelfeld-Validierung < 1 ms, unkritisch Overhead > Ersparnis Auf Main Thread belassen
DOM-Update / Rendering Zwingend erforderlich Kein DOM-Zugriff möglich Immer auf Main Thread

Die Tabelle macht das eigentliche Entscheidungskriterium sichtbar: Es geht nicht darum, möglichst viel in Worker zu verlagern, sondern gezielt die Aufgaben zu identifizieren, deren synchrone Main-Thread-Dauer den Overhead aus Serialisierung und Worker-Kommunikation klar übersteigt.

Mironsoft

Web-Performance-Engineering für Magento- und Hyvä-Shops

Main Thread entlasten, Interaktionen beschleunigen?

Wir analysieren rechenintensive JavaScript-Pfade in eurem Shop, identifizieren echte Auslagerungskandidaten und implementieren Worker-Architekturen, die INP-Werte messbar verbessern statt neuen Overhead zu erzeugen.

Main-Thread-Audit

Long-Tasks-Analyse und Priorisierung nach INP-Impact

Worker-Architektur

Comlink-Integration, Transferable Objects, Worker-Pooling

Hyvä-Integration

Alpine.js-Anbindung und CSP-konforme Worker-Bündelung

10. Zusammenfassung

Web Workers lösen ein sehr spezifisches Problem: Sie verhindern, dass CPU-intensive, DOM-freie Berechnungen den Main Thread blockieren und dadurch INP-Werte sowie die wahrgenommene Reaktionsfähigkeit verschlechtern. Der Structured Clone Algorithm hinter postMessage kopiert Daten tief und kostet bei großen, komplexen Objekten spürbar Zeit, weshalb Transferable Objects wie ArrayBuffer für numerische Massendaten die deutlich günstigere Alternative sind. Comlink reduziert den Boilerplate-Aufwand beim Worker-Handling, ersetzt aber nicht die Notwendigkeit, Serialisierungskosten bewusst zu managen.

Entscheidend ist die Faustregel: Nur Aufgaben auslagern, die synchron voraussichtlich über 50 Millisekunden beanspruchen würden, Worker einmal instanziieren und wiederverwenden statt pro Aktion neu zu erzeugen, und niemals versuchen, DOM-Zugriff in den Worker zu verlagern. Im Magento-/Hyvä-Kontext eignen sich vor allem Produktlisten-Filterung, -Sortierung und größere JSON-Verarbeitung als reale, messbare Kandidaten für diesen Ansatz.

Web Workers im Storefront - Das Wichtigste auf einen Blick

Ab 50 ms auslagern

Nur Aufgaben in Worker verlagern, die synchron über der Long-Task-Schwelle liegen würden.

Transferable statt Clone

Numerische Massendaten als ArrayBuffer übertragen, nicht per Structured Clone kopieren.

Worker wiederverwenden

Einmal instanziieren, für die Seitenlebensdauer offen halten, gezielt mit terminate() beenden.

Kein DOM-Zugriff

Ergebnisse als schmale Datenstruktur zurückschicken, Rendering bleibt auf dem Main Thread.

11. FAQ: Web Workers in Magento- und Hyvä-Shops

1Was ist ein Web Worker und wofür wird er eingesetzt?
Führt JavaScript in einem separaten Thread aus, getrennt vom Main Thread. Geeignet für CPU-intensive, DOM-freie Berechnungen wie Sortieren, Filtern oder JSON-Parsing.
2Wann lohnt sich der Einsatz eines Web Workers?
Ab Aufgaben, die synchron voraussichtlich über 50 Millisekunden dauern würden. Darunter übersteigt der Overhead meist die Ersparnis.
3Kann ein Web Worker auf das DOM zugreifen?
Nein, außer über OffscreenCanvas. Ergebnisse müssen per postMessage zurück an den Main Thread geschickt werden.
4Was ist der Structured Clone Algorithm und warum ist er relevant?
Standardmechanismus hinter postMessage, der Daten tief kopiert. Bei großen, verschachtelten Objekten kann das mehrere Dutzend Millisekunden kosten.
5Was sind Transferable Objects und wie unterscheiden sie sich von normalem postMessage?
Übertragen Ownership statt zu kopieren. Der Browser verschiebt nur einen Speicher-Pointer, unabhängig von der Datengröße.
6Was ist Comlink und wann sollte ich es einsetzen?
Abstrahiert postMessage über Proxy-basiertes RPC, Worker-Funktionen wirken wie normale async Funktionen. Sinnvoll bei mehreren Methoden, oft unnötig bei einem simplen Interface.
7Wie teuer ist das Starten eines neuen Workers?
Meist 3 bis 15 Millisekunden für kleine Skripte, bei schweren Bundles bis zu mehreren Dutzend Millisekunden. Deshalb Worker wiederverwenden statt neu erzeugen.
8Was ist der Unterschied zwischen Dedicated Worker, Shared Worker und Service Worker?
Dedicated Worker gehört zu einem Tab, Shared Worker zu mehreren Tabs derselben Origin, Service Worker fungiert als Netzwerk-Proxy für Caching. Für Berechnungsauslagerung zählt der Dedicated Worker.
9Können mehrere Worker gleichzeitig laufen und wie viele sind sinnvoll?
Ja, parallel auf mehreren Kernen. Richtwert: navigator.hardwareConcurrency minus eins. Zu viele Worker erzeugen Kontextwechsel-Overhead.
10Wann sollte ich KEINEN Web Worker verwenden?
Bei Aufgaben unter etwa 50 Millisekunden, bei DOM-abhängiger Logik und bei kleinen, einmaligen Berechnungen mit hohem relativen Overhead.