Die JavaScript-Event-Loop, die Ihre Webseite antreibt, verhält sich sehr anders als diejenige, die einen Node.js-Server steuert. Diese Diskrepanz kann eine Benutzeroberfläche (UI) einfrieren oder den I/O-Durchsatz drosseln, wenn man nicht vorsichtig ist. Zu wissen, wo die beiden divergieren, ist essenziell für jeden, der asynchronen Code schreibt, der in beiden Umgebungen ausgeführt wird.

Warum die Unterscheidung wichtig ist

Die Event Loop ist nicht durch die ECMAScript-Spezifikation definiert; sie existiert innerhalb des Hosts. Browser müssen eine Seite reaktionsfähig halten, während sie Frames rendern, während Node.js auf nicht-blockierendem I/O basiert. Das Mischen von Mustern, die in einem Host funktionieren, mit denen des anderen, kann schwer reproduzierbare Bugs verursachen: Eine lange Kette von Promises kann das Repaint eines Browsers blockieren, während eine unkontrollierte process.nextTick-Schleife verhindern kann, dass Node jemals seine I/O-Phasen erreicht.

Die schichtweise Loop des Browsers

In einem Browser durchläuft die Loop einen einzelnen Zyklus, der die Task-Ausführung, das Abarbeiten von Microtasks und das Rendering verzahnt:

  1. Führe eine Macrotask aus (ein Click-Handler, ein setTimeout usw.).
  2. Arbeite alle Microtasks ab (Promises, queueMicrotask).
  3. Falls ein Frame fällig ist, führe Paint und Composite durch, um die Zielrate von 60 fps zu erreichen.
  4. Wiederhole den Vorgang.

Zwei APIs bieten Entwicklern explizite Anknüpfungspunkte in diesen Zyklus:

  • requestAnimationFrame – wird unmittelbar vor dem Paint des Browsers aufgerufen. Dies ist der richtige Ort für Animationsarbeiten, da der Callback nach den aktuellen Microtasks, aber vor dem nächsten Frame ausgeführt wird.
  • requestIdleCallback – wird aufgerufen, wenn der Browser keine hochprioritären Aufgaben mehr hat. Dies ist nützlich für Aufgaben mit geringer Priorität, wie etwa Analysen oder das Vorladen von Daten.

Falle: Microtask-Starvation

Da der Browser die Microtask-Warteschlange leert, bevor er rendert, kann eine lange Kette von Promises verhindern, dass die UI jemals ein Bild zeichnet. Der Call Stack wird dabei nicht blockiert; die Seite erreicht einfach nie den Rendering-Schritt, was sich für den Benutzer wie ein Einfrieren anfühlt.

Die libuv-gesteuerte Loop von Node.js

Node.js delegiert seine Loop an libuv, eine C-Bibliothek, die die Arbeit in verschiedene Phasen unterteilt, von denen jede ihre eigene Warteschlange hat:

  1. Timers – Callbacks von setTimeout und setInterval.
  2. Pending callbacks – aufgeschobene I/O-Callbacks, die auf Betriebssystemebene bereits abgeschlossen wurden.
  3. Poll – ruft neue I/O-Events ab (Dateilesesen, Netzwerkdaten).
  4. Check – führt setImmediate-Callbacks aus.
  5. Close callbacks – wird ausgelöst, wenn ein Socket oder Handle geschlossen wird.

Zwei Konstrukte befinden sich außerhalb dieser Phasenreihenfolge:

  • process.nextTick – wird vor der Microtask-Warteschlange ausgeführt, unmittelbar nachdem die aktuelle Operation abgeschlossen ist.

Falle: I/O-Starvation

Wenn eine Funktion wiederholt process.nextTick plant, ohne die Kontrolle abzugeben, kommt Node niemals über den „next-tick“-Schritt hinaus. Netzwerkanfragen, Dateilesen und Timer bleiben im Leerlauf, was zu Latenzspitzen auf dem Server oder zum kompletten Stillstand führen kann.

setImmediate vs. setTimeout in der Praxis

Beide planen Callbacks für die nächste Iteration, aber ihre relative Reihenfolge hängt davon ab, wo sie aufgerufen werden:

  • Top-Level-Code – die Reihenfolge ist nicht garantiert; sie hängt davon ab, wie schnell der Prozess startet.
  • Innerhalb eines I/O-Callbacks – die Reihenfolge ist deterministisch: setImmediate wird vor setTimeout(fn, 0) ausgeführt. Nachdem die Poll-Phase abgeschlossen ist, bewegt sich libuv zur Check-Phase (in der setImmediate liegt), bevor es wieder in die Timers-Phase für ein Timeout mit Null-Verzögerung eintritt.

Diese Feinheit ist wichtig, wenn Sie auf eine präzise Sequenzierung angewiesen sind, wie etwa beim Aufräumen einer Ressource direkt nach Abschluss eines Lesevorgangs.

Die wichtigsten Unterschiede auf einen Blick

  • Ziel: Browser priorisieren visuelle Updates; Node priorisiert I/O-Bereitschaft.
  • Rendering-Hook: requestAnimationFrame (nur Browser).
  • Phasenspezifischer Hook: setImmediate (nur Node, wird in der Check-Phase ausgeführt).
  • Hochpriorisierte Warteschlange: process.nextTick (nur Node, läuft vor Microtasks).
  • Starvation-Risiko: Lange Promise-Ketten in Browsern; unbegrenzte process.nextTick-Aufrufe in Node.

Worauf Sie als Nächstes achten sollten

Wenn Sie eine Codebasis pflegen, die in beiden Umgebungen läuft (z. B. isomorphe Bibliotheken), prüfen Sie alle Stellen, an denen Sie:

  • Viele Promises verketten, ohne die Event Loop freizugeben. Fügen Sie await new Promise(r => setTimeout(r, 0)) ein oder nutzen Sie im Browser requestIdleCallback, um dem Renderer eine Chance zu geben.
  • process.nextTick für Aufgaben verwenden, die aufgeschoben werden könnten. Bevorzugen Sie setImmediate oder ein reguläres Promise, wenn keine unmittelbare Dringlichkeit („next-tick“) erforderlich ist.
  • Annehmen, dass setTimeout(fn, 0) und setImmediate austauschbar sind. Testen Sie die Reihenfolge innerhalb von I/O-Callbacks, falls die Sequenzierung entscheidend ist.

Fazit

Die Event Loop ist ein hostspezifischer Scheduler, kein universelles JavaScript-Feature. Browser weben das Rendering in die Loop ein; Node isoliert I/O in libuv-Phasen. Der Missbrauch von Prioritätsmechanismen – Microtasks im Browser, process.nextTick in Node – kann den Teil des Systems „verhungern“ lassen, für den die jeweilige Umgebung eigentlich konzipiert wurde. Wenn Sie Ihre Async-Muster auf das Loop-Modell des Hosts abstimmen, vermeiden Sie sowohl eingefrorene Seiten als auch blockierte Server.