UI-Thread frei halten, Multithreading meistern
JavaScript ist single-threaded – aber das ist kein Naturgesetz für Browser-Anwendungen. Web Workers bringen echtes Multithreading in den Browser: CPU-intensive Berechnungen, Dateiverarbeitung, Verschlüsselung und KI-Inferenz laufen in Hintergrundthreads, während der UI-Thread reaktionsfähig bleibt und Nutzerinteraktionen flüssig verarbeitet.
Inhaltsverzeichnis
- 1. Warum der UI-Thread geschützt werden muss
- 2. Dedicated Workers: Erstellen, Kommunizieren, Beenden
- 3. Transferable Objects: Daten ohne Kopie übergeben
- 4. Comlink: Worker wie normale Objekte verwenden
- 5. SharedArrayBuffer und Atomics: Synchrone Kommunikation
- 6. Worker Thread Pool: Arbeit auf mehrere Threads verteilen
- 7. Offscreen Canvas: Rendering aus dem UI-Thread auslagern
- 8. Shared Workers: Ein Worker für mehrere Browser-Tabs
- 9. Vergleich der Worker-Typen und Kommunikationsmuster
- 10. Zusammenfassung
- 11. FAQ
1. Warum der UI-Thread geschützt werden muss
JavaScript im Browser wird auf dem Haupt-Thread ausgeführt, der auch für das Rendern der Benutzeroberfläche zuständig ist. Wenn eine JavaScript-Berechnung länger als 16 ms dauert (bei 60 fps), kann der Browser keinen neuen Frame rendern – das Ergebnis ist ein spürbares Ruckeln. Wenn die Berechnung hunderte von Millisekunden dauert, friert die Benutzeroberfläche vollständig ein: Buttons reagieren nicht, Scrolling stoppt, Animationen hängen. Das ist keine theoretische Einschränkung, sondern ein alltägliches Problem bei rechenintensiven Web-Anwendungen.
Web Workers sind Javascripts offizielle Lösung für dieses Problem. Sie laufen in vollständig separaten Threads mit eigenem Heap und eigenem Event-Loop – kein geteilter State, keine Race Conditions durch gleichzeitigen Zugriff. Die Kommunikation zwischen Main Thread und Worker erfolgt über strukturiertes Klonen via postMessage. Der UI-Thread übergibt Aufgaben an den Worker, der Worker verarbeitet sie ohne den UI-Thread zu blockieren und liefert das Ergebnis zurück. Das Ergebnis: der UI-Thread bleibt reaktionsfähig, egal wie intensiv die Hintergrundarbeit ist.
2. Dedicated Workers: Erstellen, Kommunizieren, Beenden
Ein Dedicated Worker ist der einfachste Worker-Typ: Er ist exklusiv einem einzigen Skript zugeordnet und lebt so lange, wie das erzeugende Dokument lebt. Die Erstellung erfolgt mit new Worker('./worker.js') oder, bei Verwendung eines Bundlers, direkt aus dem Build-System mit new Worker(new URL('./worker.js', import.meta.url), { type: 'module' }). Der zweite Ansatz ist für moderne Projekte mit Vite oder Webpack zu bevorzugen: Der Bundler erkennt den Worker, baut ihn separat und optimiert ihn wie das Hauptbündel. Typ 'module' aktiviert außerdem ES-Module im Worker – import statt importScripts().
Die Kommunikation läuft über postMessage und onmessage-Handler. Im Main Thread: worker.postMessage(data) und worker.onmessage = (e) => { /* e.data */ }. Im Worker: self.onmessage = (e) => { /* e.data */ } und self.postMessage(result). Für robustere Kommunikation empfiehlt sich ein Nachrichtenprotokoll: jede Nachricht hat ein type-Feld und optional eine id, die Anfragen und Antworten korreliert. Ein Worker wird mit worker.terminate() explizit beendet. Nicht beendete Worker laufen weiter, auch wenn das aufrufende Skript neu geladen wird – das ist eine häufige Ressourcen-Leck-Quelle in Single-Page-Applications.
// main.js — robust message protocol with request-response correlation
class WorkerBridge {
#worker
#pending = new Map()
#nextId = 0
constructor(url) {
this.#worker = new Worker(new URL(url, import.meta.url), { type: 'module' })
this.#worker.addEventListener('message', ({ data }) => {
const resolver = this.#pending.get(data.id)
if (!resolver) return
this.#pending.delete(data.id)
data.error ? resolver.reject(new Error(data.error)) : resolver.resolve(data.result)
})
this.#worker.addEventListener('error', (e) => {
console.error('[Worker Error]', e.message, e.filename, e.lineno)
})
}
// Send a task and get a Promise back
call(type, payload, transfer = []) {
return new Promise((resolve, reject) => {
const id = ++this.#nextId
this.#pending.set(id, { resolve, reject })
// transfer: move ownership of ArrayBuffer without copying
this.#worker.postMessage({ id, type, payload }, transfer)
})
}
terminate() {
this.#worker.terminate()
// Reject all pending calls
this.#pending.forEach(({ reject }) => reject(new Error('Worker terminated')))
this.#pending.clear()
}
}
// worker.js — ES module worker with typed dispatch
self.onmessage = async ({ data: { id, type, payload } }) => {
try {
const result = await handlers[type](payload)
self.postMessage({ id, result })
} catch (e) {
self.postMessage({ id, error: e.message })
}
}
const handlers = {
sort: ({ array }) => ({ sorted: [...array].sort((a, b) => a - b) }),
hash: async ({ data }) => {
const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(data))
return { hex: Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join('') }
},
}
3. Transferable Objects: Daten ohne Kopie übergeben
Standardmäßig werden Daten zwischen Main Thread und Web Worker durch strukturiertes Klonen kopiert. Für kleine Objekte ist das kein Problem. Für große Datenmengen – z.B. einen 10 MB großen ArrayBuffer für Bild- oder Audio-Daten – ist das Kopieren teuer. Transferable Objects lösen dieses Problem: Das Objekt wird physisch in den anderen Thread übertragen, ohne kopiert zu werden. Der ursprüngliche Thread verliert den Zugriff auf das Objekt (es wird "detached") – aber dafür passiert die Übergabe in O(1), unabhängig von der Datengröße.
Transferable Typen in modernen Browsern sind: ArrayBuffer, MessagePort, OffscreenCanvas, ImageBitmap, ReadableStream, WritableStream und TransformStream. Das Transfer-Array wird als zweites Argument an postMessage übergeben. Ein häufiger Fehler: Den Buffer transferieren und dann versuchen, ihn noch im sendenden Thread zu verwenden – das wirft einen DataCloneError, weil der Buffer bereits detached ist. Das Ownership-Prinzip von Transferables ist dem Rust-Ownership-Modell ähnlich: nach dem Transfer gehört das Objekt dem anderen Thread.
4. Comlink: Worker wie normale Objekte verwenden
Comlink ist eine Bibliothek von Google, die das postMessage-Protokoll vollständig abstrahiert. Mit Comlink sieht ein Worker von außen wie ein normales JavaScript-Objekt aus, dessen Methoden Promises zurückgeben. Im Worker definiert man eine Klasse und exponiert sie mit Comlink.expose(). Im Main Thread erstellt man einen Proxy mit Comlink.wrap(worker). Danach kann man Methoden des Workers aufrufen, als wären sie lokale async-Funktionen – keine manuellen postMessage-Handler, keine Korrelations-IDs, kein Boilerplate.
Der Unterschied zwischen rohem postMessage und Comlink ist erheblich für die Wartbarkeit. Ohne Comlink muss für jede Worker-Operation ein Message-Type, ein Handler und eine Response-Korrelation implementiert werden. Mit Comlink ist es eine Methode auf einem Proxy-Objekt. Comlink unterstützt außerdem den Transfer von Objekten via Comlink.transfer(obj, [transfer]) und Callbacks in beide Richtungen. Die einzige Einschränkung: Comlink erhöht die Bundle-Größe um ca. 2 kB. Für produktive Worker-intensive Anwendungen ist das ein ausgezeichneter Trade-off.
// compute-worker.js — Comlink-exposed class in a module worker
import * as Comlink from 'comlink'
class ImageProcessor {
// Process a large Float32Array image — result transferred back
async applyConvolution(imageData, kernel) {
const { width, height, data } = imageData
const output = new Float32Array(data.length)
const kSize = Math.sqrt(kernel.length)
const half = Math.floor(kSize / 2)
// Convolution — CPU-intensive, runs off UI thread
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
let sum = 0
for (let ky = 0; ky < kSize; ky++) {
for (let kx = 0; kx < kSize; kx++) {
const px = Math.min(Math.max(x + kx - half, 0), width - 1)
const py = Math.min(Math.max(y + ky - half, 0), height - 1)
sum += data[py * width + px] * kernel[ky * kSize + kx]
}
}
output[y * width + x] = sum
}
}
// Transfer output buffer back to main thread — no copy
return Comlink.transfer({ width, height, data: output }, [output.buffer])
}
}
Comlink.expose(ImageProcessor)
// main.js — use the worker as a normal class
import * as Comlink from 'comlink'
const worker = new Worker(new URL('./compute-worker.js', import.meta.url), { type: 'module' })
const RemoteImageProcessor = Comlink.wrap(worker)
// Instantiate and call — looks like a normal async class
const processor = await new RemoteImageProcessor()
const result = await processor.applyConvolution(imageData, sharpenKernel)
console.log('Processed:', result.data.length, 'pixels')
5. SharedArrayBuffer und Atomics: Synchrone Kommunikation
SharedArrayBuffer ist das fortgeschrittene Kommunikationsmuster für Web Workers: Statt Daten zu kopieren oder zu transferieren, teilen sich Main Thread und Worker denselben Speicherbereich. Beide können gleichzeitig lesen und schreiben. Das klingt nach einem klassischen Race-Condition-Problem – und das wäre es, ohne Atomics. Die Atomics-API bietet atomare Operationen: Atomics.add(), Atomics.compareExchange(), Atomics.wait() und Atomics.notify(). Diese Operationen sind unteilbar – kein anderer Thread kann dazwischenfunken.
Das praktischste Atomics-Muster ist Atomics.wait() und Atomics.notify(): Ein Worker-Thread wartet auf einen SharedArrayBuffer-Slot, der Main Thread schreibt Daten in den Buffer und ruft dann Atomics.notify() auf, um den Worker aufzuwecken. Das ermöglicht Semaphoren, Locks und Producer-Consumer-Queues zwischen Threads. SharedArrayBuffer erfordert Cross-Origin-Isolation: Die Seite muss die HTTP-Header Cross-Origin-Opener-Policy: same-origin und Cross-Origin-Embedder-Policy: require-corp senden. Das ist eine Sicherheitsanforderung, die nach dem Spectre-Angriff 2018 eingeführt wurde.
6. Worker Thread Pool: Arbeit auf mehrere Threads verteilen
Für Anwendungen, die viele unabhängige Aufgaben verarbeiten, ist ein Worker Thread Pool die richtige Architektur. Statt für jede Aufgabe einen neuen Worker zu erstellen (was Startup-Overhead hat) und sofort zu beenden (was Ressourcen verschwendet), hält ein Pool eine feste Anzahl von Worker-Threads bereit. Eingehende Aufgaben werden an freie Worker verteilt. Ein Pool mit navigator.hardwareConcurrency Threads entspricht der Anzahl der physischen CPU-Kerne des Geräts – das ist der optimale Ausgangspunkt für CPU-bound Arbeit.
Ein robuster Worker Pool benötigt eine Warteschlange für Aufgaben, die ankommen, wenn alle Worker beschäftigt sind, und eine Mechanismus, um fertige Worker wieder als verfügbar zu markieren. Das Grundprinzip ist simpel: Ein Array von Worker-Instanzen, ein Array von ausstehenden Tasks, und bei Task-Abschluss wird der Worker sofort mit dem nächsten Task aus der Queue belegt. Für produktionsreife Implementierungen gibt es Bibliotheken wie workerpool und threads.js, die Worker-Pools mit vollständigem Promise-API und TypeScript-Support bieten.
// Minimal but robust Worker Thread Pool implementation
class WorkerPool {
#workers = []
#queue = []
#busy = new Set()
constructor(workerUrl, size = navigator.hardwareConcurrency ?? 4) {
for (let i = 0; i < size; i++) {
const worker = new Worker(new URL(workerUrl, import.meta.url), { type: 'module' })
this.#workers.push(worker)
}
}
// Submit a task — returns a Promise with the result
exec(type, payload, transfer = []) {
return new Promise((resolve, reject) => {
const task = { type, payload, transfer, resolve, reject }
const available = this.#workers.find(w => !this.#busy.has(w))
if (available) {
this.#dispatch(available, task)
} else {
this.#queue.push(task)
}
})
}
#dispatch(worker, { type, payload, transfer, resolve, reject }) {
this.#busy.add(worker)
const onMessage = ({ data }) => {
cleanup()
data.error ? reject(new Error(data.error)) : resolve(data.result)
this.#busy.delete(worker)
if (this.#queue.length > 0) {
this.#dispatch(worker, this.#queue.shift())
}
}
const onError = (e) => {
cleanup()
reject(new Error(e.message))
this.#busy.delete(worker)
}
const cleanup = () => {
worker.removeEventListener('message', onMessage)
worker.removeEventListener('error', onError)
}
worker.addEventListener('message', onMessage, { once: true })
worker.addEventListener('error', onError, { once: true })
worker.postMessage({ type, payload }, transfer)
}
terminate() {
this.#workers.forEach(w => w.terminate())
this.#queue.forEach(({ reject }) => reject(new Error('Pool terminated')))
this.#queue.length = 0
}
}
// Usage: process 1000 images concurrently across all CPU cores
const pool = new WorkerPool('./image-worker.js')
const results = await Promise.all(
images.map(img => pool.exec('resize', { data: img.buffer, width: 800 }, [img.buffer]))
)
7. Offscreen Canvas: Rendering aus dem UI-Thread auslagern
Offscreen Canvas ist eine Erweiterung der Canvas-API, die ermöglicht, Canvas-Rendering vollständig in einem Web Worker durchzuführen. Normalerweise läuft Canvas-Rendering auf dem UI-Thread und konkurriert mit Event-Handling und JavaScript-Ausführung um Renderzeit. Mit canvas.transferControlToOffscreen() wird die Kontrolle über ein Canvas-Element an einen Worker übertragen. Der Worker erhält ein OffscreenCanvas-Objekt und kann darauf genau wie auf ein reguläres Canvas zeichnen – mit getContext('2d') für 2D-Grafik oder getContext('webgl2') für WebGL-Rendering.
Der Hauptvorteil von Offscreen Canvas: Ein komplexes Spiele-Rendering, eine Daten-Visualisierung oder eine Echtzeit-Grafik-Verarbeitung blockiert den UI-Thread nicht mehr. Selbst wenn das Rendering 50 ms pro Frame dauert, bleibt das restliche Interface reaktionsfähig. Für WebGL-Anwendungen ist das besonders wertvoll: Shader-Kompilierung, Textur-Uploads und komplexe Render-Passes können alle im Worker-Thread ablaufen. Der Browser kümmert sich darum, das gerenderte Bild synchron zum UI-Thread zu übertragen und auf dem Bildschirm anzuzeigen – transparent für den Entwickler.
8. Shared Workers: Ein Worker für mehrere Browser-Tabs
Ein Shared Worker ist ein Web Worker, der von mehreren Browsing-Kontexten gleichzeitig verwendet werden kann – mehrere Tabs, Frames oder Fenster der gleichen Origin können sich denselben Shared Worker teilen. Das ist der richtige Ansatz für Aufgaben, die über Tab-Grenzen hinweg koordiniert werden müssen: WebSocket-Verbindungen (eine Verbindung für alle Tabs statt eine pro Tab), gemeinsame Datenbankoperationen oder geteilter State zwischen Tabs. Ein Shared Worker erhält Verbindungen über das connect-Event und kommuniziert mit jedem Kontext über einen eigenen MessagePort.
Die Einschränkung von Shared Workers: Sie werden erst beendet, wenn alle verbundenen Kontexte geschlossen sind. Das ist das gewünschte Verhalten für langlebige Verbindungen, kann aber zu Ressourcenlecks führen, wenn Worker nicht korrekt von ihren Kontexten getrennt werden. Firefox unterstützt Shared Workers vollständig, Chrome ebenfalls. Safari hatte lange Probleme damit, unterstützt Shared Workers aber seit Safari 16 wieder. Für einfache Tab-Koordination ohne den Overhead eines Shared Workers ist auch die BroadcastChannel API eine leichtgewichtige Alternative – ohne Worker, einfach ein Pub/Sub-Mechanismus zwischen Tabs derselben Origin.
| Worker-Typ | Scope | Kommunikation | Typischer Einsatz |
|---|---|---|---|
| Dedicated Worker | Ein Kontext | postMessage / Comlink | CPU-intensive Einzelaufgaben |
| Shared Worker | Mehrere Tabs/Frames | MessagePort pro Kontext | Geteilte WebSocket-Verbindung |
| Service Worker | Origin-weit | fetch/push/sync Events | Offline-Caching, Push-Notifications |
| Worker Pool | Ein Kontext | postMessage / Queue | Parallelverarbeitung vieler Tasks |
| Audio Worklet | Audio-Thread | AudioWorkletNode / SAB | Echtzeit-Audio-Verarbeitung |
9. Vergleich der Worker-Typen und Kommunikationsmuster
Die Wahl des richtigen Kommunikationsmusters für Web Workers hat erheblichen Einfluss auf die Ergonomie und Performance. Rohes postMessage ist das Fundament – maximale Kontrolle, maximaler Boilerplate. Comlink abstrahiert postMessage zu Promise-basierenden Methodenaufrufen – empfohlen für die meisten Anwendungen. SharedArrayBuffer mit Atomics ist das Hochleistungsmuster für Situationen, wo Millionen von Datenpunkten zwischen Threads ausgetauscht werden müssen und Kopierverlust vermieden werden soll. Transferable Objects sind der Mittelweg: Zero-Copy bei asynchroner Übergabe.
Für die Auswahl gilt: Erst prüfen, ob das Problem wirklich einen Worker erfordert. Nicht alle langen Berechnungen brauchen einen Worker – manchmal reicht es, die Arbeit in Microtasks zu zerstückeln mit scheduler.yield() (neu in Chrome 115) oder durch manuelles Chunking mit setTimeout(0). Web Workers machen dann Sinn, wenn die Aufgabe wirklich CPU-bound ist, wenn sie eine eigene Laufzeit-Isolation braucht, oder wenn mehrere Aufgaben parallel ausgeführt werden sollen. Für I/O-bound Aufgaben (API-Calls, Datei-Lesen) bringt ein Worker keinen Vorteil – dafür ist Async/Await die richtige Wahl.
10. Zusammenfassung
Web Workers sind das wichtigste Performance-Werkzeug für rechenintensive Browser-Anwendungen. Dedicated Workers für einzelne CPU-bound Aufgaben, Worker Pools für parallele Batch-Verarbeitung, Shared Workers für Tab-übergreifende Koordination und Offscreen Canvas für UI-Thread-freies Rendering – jeder Typ hat seinen Einsatzbereich. Die Kommunikationsmuster skalieren von simpel (Comlink für die meisten Fälle) bis hochoptimiert (SharedArrayBuffer für maximalen Durchsatz).
Die praktische Empfehlung: Mit Comlink beginnen, weil es den Boilerplate auf ein Minimum reduziert und sofort produktiven Code ermöglicht. Wenn die Performance-Anforderungen steigen, Transferable Objects für große Datenpuffer einsetzen. SharedArrayBuffer und Atomics nur dann, wenn wirklich synchrone Koordination zwischen Threads benötigt wird – und Cross-Origin-Isolation sicherstellen. Worker am Ende des Lebenszyklus immer explizit mit terminate() beenden, um Ressourcen freizugeben.
Web Workers in JavaScript — Das Wichtigste auf einen Blick
Comlink bevorzugen
Comlink.expose() im Worker, Comlink.wrap() im Main Thread. Worker-Methoden werden zu async-Funktionen. Kein Boilerplate, keine manuellen IDs.
Transferable Objects
ArrayBuffer als zweites Argument übergeben: postMessage(data, [buffer]). Zero-Copy — nach dem Transfer kein Zugriff mehr vom Sender möglich.
Worker Pool für Batch
navigator.hardwareConcurrency Threads für optimale CPU-Auslastung. Queue für wartende Tasks, Worker nach Abschluss sofort weiter belasten.
Worker terminieren
worker.terminate() nach Gebrauch aufrufen. Nicht beendete Workers laufen weiter, auch nach Page-Navigation — Ressourcen-Leak in SPAs.
Mironsoft
JavaScript-Performance, Web Workers und Browser-Architektur
UI-Thread-Probleme in eurer Web-App lösen?
Wir analysieren eure Performance-Probleme mit dem Chrome Performance-Profiler, identifizieren Long Tasks auf dem UI-Thread und migrieren rechenintensive Operationen in Web Workers – mit Comlink, Transferables und Worker Pools.
Performance-Audit
Long-Task-Analyse, Main-Thread-Auslastung und Identifikation von Worker-Kandidaten
Worker-Migration
Rechenintensive Operationen mit Comlink und Transferables in Worker-Threads auslagern
Worker Pool Design
Architektur und Implementierung eines robusten Worker-Thread-Pools für Batch-Verarbeitung