Warum der Browser stehen bleibt, wenn dein Code rechnet
Ein einziger Strang bedient Klicks, Layout und Zeichnen. Wer ihn zwei Sekunden am Stück belegt, hat für zwei Sekunden keine bedienbare Seite, unabhängig davon, wie schnell der Rechner ist.
KI-generiertDieses Bild wurde mit KI erzeugt · Yves Hoppe / KI / cmt
Zwischen Klick und Bild liegt genau eine Warteschlange
Der Ablauf, den fast jeder schon gebaut hat: Beim Klick auf Auswerten setzt du den Text auf Bitte warten und startest anschließend die Berechnung. Der Text erscheint nie. Beides steht in derselben Aufgabe, und der Browser zeichnet erst, wenn die Aufgabe komplett abgearbeitet ist. Sichtbar wird dann nur der Endzustand, der Hinweis dazwischen war zu keinem Zeitpunkt auf dem Bildschirm.
Teuer ist das nicht wegen der Wartezeit selbst, sondern wegen all dessen, was in dieser Zeit nicht geht. Klicks, Tastendrücke und Scrollen werden nicht verworfen, sondern gesammelt und danach in einem Schwall abgearbeitet. Wer zwischendurch dreimal auf denselben Knopf drückt, löst die Aktion dreimal aus. Ein Formular, das während der Rechnerei bedient wurde, verarbeitet Eingaben in einer Reihenfolge, die niemand vorhergesehen hat.
Erschwerend kommt hinzu, dass die Sache auf Entwicklungsrechnern kaum auffällt. Dieselbe Schleife, die auf einem aktuellen Notebook 80 Millisekunden braucht, kann auf einem drei Jahre alten Firmengerät oder einem Mittelklasse-Telefon ein Vielfaches davon kosten. Gemeldet wird das dann als die Seite hängt, ohne Fehlermeldung und ohne Stack, weil technisch nichts kaputt ist.
KI-generiertDieses Bild wurde mit KI erzeugt · Yves Hoppe / KI / cmt
Fünf Beobachtungen und ihre tatsächliche Ursache
Blockierte Oberflächen melden sich fast nie mit einer Fehlermeldung, sondern mit einer Beschreibung aus dem Alltag. Die folgenden fünf Beschreibungen decken den größten Teil der Fälle ab, und jede zeigt auf eine andere Stelle im Ablauf.
Symptom
Nach dem Klick auf Auswerten passiert zwei Sekunden lang gar nichts, der Ladehinweis erscheint überhaupt nicht.
Ursache
Das Setzen des Hinweises und die Berechnung liegen in derselben Aufgabe. Gezeichnet wird erst danach, und dann ist der Hinweis schon wieder entfernt.
Lösung
Den Hinweis setzen, die Aufgabe beenden und die Berechnung im nächsten Durchgang starten, etwa über setTimeout mit 0 oder scheduler.yield. Ein await auf ein erfülltes Promise genügt nicht, weil Microtasks noch zur selben Aufgabe gehören.
Symptom
Der Fortschrittsbalken springt am Ende von 0 auf 100, obwohl er in der Schleife hochgezählt wird.
Ursache
Alle DOM-Änderungen innerhalb einer Aufgabe werden zusammengefasst, gezeichnet wird ausschließlich der letzte Stand.
Lösung
Die Schleife in Blöcke aufteilen und nach jedem Block an das Ereignisloop zurückgeben, oder die Berechnung in einen Worker verlagern, der den Fortschritt über postMessage meldet.
Symptom
Beim Tippen in das Suchfeld erscheinen die Buchstaben verzögert und dann mehrere auf einmal.
Ursache
Bei jedem Tastendruck läuft die Filterung über die vollständige Liste, und die Tastenereignisse stauen sich hinter diesen Aufgaben.
Lösung
Die Filterung verzögert auslösen statt bei jedem Zeichen, den Suchtext einmal vorbereiten statt bei jedem Durchlauf neu, und nur den sichtbaren Ausschnitt in das DOM schreiben.
Symptom
Die Seite nimmt gar keine Klicks mehr an, nach einer Weile fragt der Browser, ob er sie beenden soll.
Ursache
Eine Endlosschleife, oder eine Kette von Microtasks, die sich in jedem Durchgang selbst neu einplant. Da die Microtask-Warteschlange vor dem Zeichnen vollständig geleert wird, kommt der Browser nie zum Bild.
Lösung
Im Performance-Panel die betroffene Aufgabe suchen, den Abbruch der Rekursion prüfen und eine Ende-Bedingung ergänzen. Eine reine Transform-Animation läuft übrigens weiter, weil der Compositor sie ohne den Hauptstrang bedient, ein per JavaScript bewegter Balken nicht.
Symptom
Auf dem eigenen Rechner läuft alles flüssig, im Support-Ticket steht, die Seite sei unbenutzbar.
Ursache
Entwicklungsgeräte sind schneller als die Geräte im Feld, und Testdaten sind meist kleiner als echte Datenbestände.
Lösung
Im Performance-Panel die CPU-Drosselung einschalten, mit einer realistischen Datenmenge messen und lange Aufgaben zusätzlich im Feld erfassen, in Chromium-Browsern über einen PerformanceObserver auf longtask.
Ein Bild entsteht in fünf Schritten, jeder wartet auf den vorigen
- 01 Ein Ereignis wird zur Aufgabe und wartet, bis die laufende Aufgabe endet.
- 02 Nach jeder Aufgabe leert der Browser die komplette Microtask-Warteschlange.
- 03 Erst danach laufen die Rückrufe von requestAnimationFrame.
- 04 Anschließend berechnet der Browser Stil und Layout der geänderten Elemente.
- 05 Zuletzt wird gezeichnet, frühestens jetzt siehst du deine DOM-Änderung.
Was du danach an einer hakenden Seite als Erstes tust
Fast jede hakende Oberfläche lässt sich auf eine von drei Ursachen zurückführen: eine zu lange Aufgabe, zu viele kleine Aufgaben aus einem Ereignis, oder Lesen und Schreiben am DOM im Wechsel. Die Reihenfolge der Prüfung ist immer dieselbe, und sie beginnt mit einer Messung statt mit einer Vermutung.
Die lange Aufgabe im Profil finden
Eine Aufnahme im Performance-Panel zeigt jede Aufgabe als Balken mit ihrer Dauer und der Funktion, die sie ausgelöst hat. Ohne diese Aufnahme optimierst du die Stelle, die am leichtesten zu finden war, statt der Stelle, die den Ausschlag gibt.
Arbeit in Abschnitte schneiden
Statt zehntausend Einträge in einem Durchlauf zu verarbeiten, rechnest du in Blöcken und merkst dir den Index. Zwischen den Blöcken kehrt der Browser ans Zeichnen und an die Ereignisse zurück, die Gesamtdauer bleibt fast gleich, die Seite bleibt bedienbar.
Wirklich zurückgeben, nicht nur scheinbar
Ein await auf ein bereits erfülltes Promise bleibt in derselben Aufgabe und erlaubt kein Zeichnen. Nur eine echte Aufgabengrenze hilft, also setTimeout, eine Nachricht über einen MessageChannel oder scheduler.yield, das Chromium-Browser und Firefox ab Version 142 kennen.
Ereignisse entzerren
Eingabe, Scrollen und Größenänderungen feuern deutlich häufiger als der Bildschirm neu zeichnet. Mit einer Verzögerung beim Tippen und einer Kopplung an requestAnimationFrame beim Scrollen rechnest du einmal pro Bild statt zwanzigmal.
Lesen und Schreiben am DOM trennen
Wer in einer Schleife abwechselnd eine Größe ausliest und einen Stil setzt, zwingt den Browser jedes Mal zu einer sofortigen Layoutberechnung. Erst alle Werte lesen, dann alle Änderungen schreiben, macht aus hundert Zwangsberechnungen eine.
Rechnen auslagern
Ein Web Worker hat einen eigenen Ausführungsstrang und keinen Zugriff auf das DOM. Für Parsen, Sortieren, Zusammenfassen und Bildverarbeitung ist das der sauberste Weg, weil die Oberfläche währenddessen vollständig bedienbar bleibt.
KI-generiertDieses Bild wurde mit KI erzeugt · Yves Hoppe / KI / cmt
Ein Strang für alles
Der Hauptstrang einer Seite führt nicht nur dein JavaScript aus. Er wertet auch Stile aus, berechnet das Layout, zeichnet und nimmt Ereignisse entgegen. Diese Arbeiten laufen nicht ineinander, sondern nacheinander: Der Browser holt sich eine Aufgabe aus der Warteschlange, arbeitet sie vollständig ab, leert danach die Microtask-Warteschlange und prüft erst dann, ob ein neues Bild fällig ist.
Daraus folgt die Regel, die den meisten Ärger erklärt: Zwischen dem Beginn und dem Ende deiner Funktion kann kein Bild entstehen. Bei einer Bildwiederholrate von 60 Hertz steht alle 16 bis 17 Millisekunden ein neues Bild an. Eine Funktion, die 300 Millisekunden läuft, kostet also rund 18 ausgelassene Bilder, und in dieser Zeit reagiert kein Knopf.
Eine Ausnahme lohnt sich zu kennen: Animationen, die nur transform und opacity verändern, werden vom Compositor bedient und laufen weiter, während der Hauptstrang blockiert. Ein CSS-Ladekreisel dreht sich also möglicherweise munter weiter, während die Seite längst nicht mehr reagiert. Als Beleg dafür, dass alles in Ordnung ist, taugt er deshalb nicht.
Woran du eine zu lange Aufgabe erkennst
Die Long Tasks API zieht die Grenze bei 50 Millisekunden: Alles darüber gilt als lange Aufgabe, weil ab dieser Dauer eine Eingabe spürbar verzögert beantwortet wird. Im Performance-Panel sind solche Aufgaben rot markiert, und die Aufrufhierarchie darunter zeigt, welche Funktion den Anteil trägt. Nimm die Aufnahme immer mit eingeschalteter CPU-Drosselung auf, sonst misst du das Gerät, das die wenigsten Nutzer haben.
Für den laufenden Betrieb hilft ein PerformanceObserver, der Einträge vom Typ longtask entgegennimmt und Dauer und Auslöser meldet. Diese Schnittstelle gibt es bislang nur in Chromium-Browsern, für einen Überblick über die betroffenen Seiten reicht sie trotzdem. Wichtig ist die Unterscheidung: Diese Messung sagt dir, dass eine Aufgabe zu lang war, nicht warum. Das Warum steht im Profil.
Ein zweiter Blick lohnt sich an der Stelle, an der die Arbeit entsteht. Sehr oft ist es nicht eine große Berechnung, sondern eine mittelgroße, die dreißigmal pro Sekunde aus einem Ereignis heraus gestartet wird. Im Profil sieht das aus wie ein dichter Zaun aus kleinen Balken, und dieser Zaun ist genauso wirksam wie ein einzelner langer Balken.
Aufteilen, ohne die Logik zu zerreißen
Das Muster ist immer dasselbe: Die Schleife bekommt einen Index als Zustand, verarbeitet einen Block fester Größe und plant sich selbst für den nächsten Block ein. Wichtig ist die Blockgröße. Zu klein bedeutet, dass die Verwaltung mehr kostet als die Arbeit, zu groß bringt das Problem zurück. Ein üblicher Ansatz ist, die Zeit zu messen und den Block zu beenden, sobald einige Millisekunden verbraucht sind.
Für die Grenze zwischen zwei Blöcken ist die Wahl des Werkzeugs entscheidend. setTimeout ist der klassische Weg, hebt aber ab der sechsten verschachtelten Ebene auf mindestens vier Millisekunden an. Eine Nachricht über einen MessageChannel umgeht diese Anhebung. scheduler.postTask nimmt zusätzlich eine Priorität entgegen, und scheduler.yield gibt gezielt an den Browser ab und setzt danach mit erhöhter Priorität fort, damit dein Vorgang nicht hinter jeder neuen Arbeit landet. Beide kennen Chromium-Browser und Firefox ab Version 142, Safari bisher nicht, ein Rückfallweg über MessageChannel oder setTimeout gehört also dazu.
Für Arbeit, die nicht dringend ist, etwa das Vorbereiten von Daten für einen später sichtbaren Bereich, ist requestIdleCallback gedacht: Der Rückruf bekommt eine Restzeit mitgeteilt und soll aufhören, bevor sie aufgebraucht ist. Verlass dich nicht darauf, dass er überhaupt aufgerufen wird, wenn die Seite dauerhaft beschäftigt ist. Die Option timeout im zweiten Argument, also requestIdleCallback(rueckruf, { timeout: 2000 }), erzwingt den Aufruf spätestens nach dieser Frist und gehört deshalb dazu.
Wann ein Web Worker die richtige Antwort ist
Ein Worker läuft in einem eigenen Ausführungsstrang, hat kein document, kein window und keinen Zugriff auf das DOM. Genau das macht ihn geeignet für alles, was rechnet und nichts anzeigt: große Datenmengen filtern und zusammenfassen, JSON parsen, Text durchsuchen, Bilder umrechnen, Prüfsummen bilden. Die Oberfläche bleibt in dieser Zeit vollständig bedienbar, auch wenn die Berechnung Sekunden dauert.
Der Preis ist die Datenübergabe. postMessage kopiert die Daten mit dem strukturierten Klonen, und diese Kopie kostet Zeit im Hauptstrang, ungefähr proportional zur Datenmenge. Bei großen Puffern lohnt sich deshalb die Übergabe als Transferable: Ein ArrayBuffer wechselt dann den Besitzer, ohne kopiert zu werden, ist danach aber auf der abgebenden Seite leer. Für gemeinsam genutzten Speicher über SharedArrayBuffer müssen zusätzlich die Header für Cross-Origin-Isolation gesetzt sein, das ist eine Entscheidung des Betriebs und nicht des Codes.
Rechne den Start des Workers mit ein. Ein Worker ist kein Gratisobjekt, das Laden und Auswerten seines Skripts kostet Zeit. Für eine Aufgabe, die zweimal am Tag läuft, lohnt sich das selten. Für eine, die bei jeder Filteränderung läuft, praktisch immer, und dann hältst du den Worker am Leben, statt ihn jedes Mal neu zu erzeugen.
Ereignisse, die sich selbst stauen
Scrollen, Mausbewegungen und Größenänderungen lösen mehr Ereignisse aus, als Bilder gezeichnet werden. Wer in jedem dieser Rückrufe rechnet, erzeugt Arbeit, die niemand sieht. Die übliche Antwort ist, den Zustand im Ereignis nur zu merken und die eigentliche Arbeit in einem requestAnimationFrame zu erledigen, also genau einmal pro Bild.
Bei Zuhörern für wheel und touchstart kommt ein zweiter Punkt dazu: Solange der Browser nicht weiß, ob der Rückruf preventDefault aufruft, muss er mit dem Scrollen warten. Mit der Option passive erklärst du das Gegenteil, und das Scrollen läuft unabhängig von deinem Code weiter. Umgekehrt darfst du dann preventDefault nicht mehr aufrufen, der Browser ignoriert es und meldet eine Warnung.
Der dritte Klassiker ist das abwechselnde Lesen und Schreiben. offsetHeight, getBoundingClientRect und getComputedStyle zwingen den Browser dazu, ausstehende Änderungen sofort zu berechnen. Steht so ein Aufruf hinter einer Stiländerung in derselben Schleife, entsteht pro Durchlauf eine vollständige Layoutberechnung. Trennst du beides in zwei Durchläufe, bleibt eine übrig. Für Sichtbarkeit und Größe sind IntersectionObserver und ResizeObserver ohnehin die genaueren Werkzeuge, weil sie ohne eigene Messungen im Scroll-Rückruf auskommen.
Dazu passende Kurse
In den JavaScript-Kursen bei cmt gehst du an eigenen Aufnahmen aus dem Performance-Panel durch, was den Browser bei langen Berechnungen ausbremst .
Weil sich Layout, Stile und Skripte gegenseitig ausbremsen können, lohnt sich der Blick über die Sprache hinaus, dafür gibt es Kurse, die Frontend-Technik im Zusammenhang zeigen .
Wie sicher bist du beim Thema wirklich?
Lesen fühlt sich schnell nach Können an. Ein kurzer Test zeigt dir, was davon schon sitzt und wo sich ein Kurs lohnt. Kostenlos, ohne Anmeldung, mit einer Erklärung zu jeder Antwort.
Wo du genau das übst
Ein sehr gutes, praxisorientiertes und nachhaltiges Seminar. So sollte es immer sein. Vielen Dank.
Die Schulung ist gerade für Einsteiger oder Entwickler*innen mit eingestaubtem Basiswissen sehr hilfreich.
Ich bin sehr glücklich Franz als Trainer gehabt zu haben. Er hat offensichtlich unglaublich viel Wissen zu den Themen, und schafft es, dieses auch interaktiv und verständlich weiterzugeben.
Häufige Fragen
Hilft async und await gegen eine blockierende Berechnung?
Was ist im Alltag der Unterschied zwischen Microtask und Aufgabe?
Warum bringt setTimeout mit 0 überhaupt etwas?
Ab wann ist eine Aufgabe zu lang?
Deine Ansprechpartner
Du bist dir nicht sicher, welcher Kurs oder welches Level zu dir passt? Wir beraten dich persönlich und kostenlos.
Yves Hoppe
Weiterbildung & Beratung
Hilft dir, aus dem Webentwicklung-Programm den passenden Kurs für deinen Stand zu finden.
Norbert Jansen
Beratung & Inhouse
Plant mit dir Inhouse-Trainings, die auf eure Abläufe und euren Datenbestand zugeschnitten sind.
Oberflächen, die auch unter Last bedienbar bleiben
In den JavaScript-Kursen bei cmt misst du solche Fälle im Profil, teilst die Arbeit auf und siehst am eigenen Beispiel, was der Wechsel in einen Worker tatsächlich bringt.