Single-Threaded, aber nicht sequentiell
JavaScript läuft in einem einzigen Thread. Es gibt genau einen Call Stack, und zu jedem Zeitpunkt wird exakt eine Funktion ausgeführt. Zwei Zeilen deines Codes laufen innerhalb desselben Realms niemals parallel.
Das klingt erstmal nach einer harten Einschränkung – bis man sich klarmacht, was JavaScript eigentlich die meiste Zeit tut: Daten abrufen, auf Klicks warten, Dateien lesen. Der Großteil der „Arbeit" besteht aus Warten. Und genau hier kommt der Event Loop ins Spiel: Er macht dieses Warten praktisch kostenlos. Dein Code reicht eine Aufgabe an den Browser oder an Node weiter, macht in der Zwischenzeit etwas anderes, und bekommt Bescheid, sobald das Ergebnis fertig ist.
erster und zweiter werden der Reihe nach ausgegeben. dritter kommt danach – obwohl das Timeout auf 0 steht. Genau diese Verzögerung ist der Event Loop in Aktion, und zu verstehen, warum das so ist, ist der ganze Sinn dieser Seite.
Der Call Stack in JavaScript
Jeder Funktionsaufruf legt einen Frame auf den Call Stack. Sobald die Funktion zurückkehrt, wird ihr Frame wieder entfernt. Der Stack ist eben ein Stack – last in, first out.
Wenn outer() läuft, legt Node zuerst outer auf den Stack, dann inner. Sobald inner den Wert "done" zurückgibt, wird es vom Stack genommen, danach outer. Der Call Stack ist wieder leer. Genau auf diesen Moment – den leeren Stack – wartet die Event Loop.
Synchroner Code läuft auf dem Stack komplett durch, von Anfang bis Ende. Nichts Asynchrones kann dazwischenfunken. Schreibst du eine while (true)-Schleife, wird der Stack nie leer und die Seite friert ein – keine Klicks, keine Timer, keine Promise-Callbacks. Die Event Loop hat schlicht keine Chance, dran zu kommen.
Wo asynchrone Arbeit wirklich passiert
JavaScript selbst weiß gar nicht, wie man einen Netzwerk-Request absetzt oder 100 Millisekunden wartet. Diese APIs gehören zur Host-Umgebung – also zum Browser oder zu Node. Wenn du setTimeout(fn, 100) aufrufst, läuft Folgendes ab:
- Der Timer wird beim Host registriert.
setTimeoutkehrt sofort zurück. Der Stack arbeitet weiter.- Nach 100 ms legt der Host
fnin eine Queue. - Sobald der Stack leer ist, holt die Event Loop
fnaus der Queue und führt es aus.
Der Timer-Callback kann erst starten, wenn die for-Schleife und console.log("Ende") fertig sind – der Call Stack ist ja noch nicht leer. Timer definieren nur eine Mindestverzögerung, keine garantierte Ausführungszeit.
Zwei Queues: Task Queue und Microtask Queue
Es gibt nicht nur eine Queue, sondern zwei. Und genau dieser Unterschied erklärt die meisten Überraschungen rund um den Event Loop.
- Task Queue (manchmal auch Macrotask Queue genannt):
setTimeout,setInterval, I/O-Callbacks, UI-Events. - Microtask Queue: Promise-Callbacks (
.then,.catch,.finally), Fortsetzungen nachawaitund alles, was überqueueMicrotaskeingereiht wird.
Die Regel, nach der der Event Loop arbeitet, sieht so aus:
- Einen Task aus der Task Queue ausführen.
- Die Microtask Queue komplett leeren – inklusive aller Microtasks, die währenddessen noch dazukommen.
- Bei Bedarf rendern (im Browser).
- Zurück zu Schritt 1.
Microtasks laufen also immer vor dem nächsten Task. Genau deshalb verwirrt folgendes Beispiel viele:
Die Ausgabereihenfolge lautet: sync 1, sync 2, promise, timeout. Zuerst läuft der synchrone Code durch. Danach leert sich der Call Stack. Anschließend arbeitet der Event Loop die Microtask Queue ab (promise). Und erst ganz zum Schluss wird der Timer-Task (timeout) abgeholt.
Microtasks können Tasks aushungern
Da die Microtask Queue immer komplett leergeräumt wird, bevor der nächste Task an die Reihe kommt, blockiert eine Microtask, die ständig neue Microtasks nachlegt, die Task Queue auf unbestimmte Zeit:
Der Timer würde nie feuern, weil jeder Microtask einen weiteren Microtask einreiht und die Event Loop die Queue nie leerlaufen lässt. Promise-Ketten sind unproblematisch, denn jedes .then plant genau eine Fortsetzung ein – aber selbstgebaute Microtask-Schleifen sind eine Stolperfalle, die man kennen sollte.
await ist nur syntaktischer Zucker für einen Microtask
Wenn du ein Promise mit await abwartest, pausiert die Funktion, und der Rest wird als Microtask eingeplant, sobald das Promise aufgelöst ist. Da steckt keine Magie dahinter – unter der Haube ist das schlicht ein .then.
Ausgabe: A, C, B. Das await gibt die Kontrolle an den Aufrufer zurück. console.log("C") läuft noch auf dem aktuellen Call Stack ab. Erst danach wird die Microtask Queue abgearbeitet, und der Rest von demo läuft weiter – B wird ausgegeben.
Behalte das im Hinterkopf, wenn du asynchronen Code liest: await blockiert nicht, sondern gibt die Kontrolle ab.
Ein komplettes Beispiel: die Reihenfolge verstehen
Jetzt bringen wir alle Bausteine zusammen:
Reihenfolge:
1: sync— läuft auf dem Stack.6: sync— ebenfalls noch auf dem Stack.- Der Stack leert sich. Die Microtask Queue wird abgearbeitet:
3: promise,5: microtaskund dann4: nested microtask(wird während des Abarbeitens eingereiht und trotzdem noch drangenommen). - Als Nächstes folgt der nächste Task:
2: timeout.
Endergebnis: 1, 6, 3, 5, 4, 2. Wer diesen Ablauf nachvollziehen kann, hat den Event Loop verstanden.
Node hat mehr Phasen
Der Event Loop in Node ist eine Erweiterung des Browser-Modells. Er besteht aus klar getrennten Phasen — timers, pending I/O callbacks, poll, check, close — und zwischen jeder Phase werden die Microtasks abgearbeitet. setImmediate läuft in der check-Phase, process.nextTick sogar vor den regulären Microtasks (es hat seine eigene Queue mit noch höherer Priorität).
Du musst das Phasendiagramm nicht gleich am ersten Tag auswendig können. Die Kernaussage ist dieselbe wie im Browser: Zuerst läuft der synchrone Code vollständig durch, dann werden die Microtasks geleert, und erst danach greift sich der Loop den nächsten Callback aus der Queue.
Warum das Ganze wichtig ist
Sobald das Modell einmal sitzt, verliert viel asynchroner Code seinen Mystery-Faktor:
- Eine lange
for-Schleife friert deine UI ein, weil der Event Loop schlicht nicht zum Zug kommt. setTimeout(fn, 0)ist ein Mittel, um Arbeit bis nach dem aktuellen Task samt Microtasks aufzuschieben.- Ein
.then-Callback, der nach einem bereits resolvten Promise "sofort" laufen soll, wartet trotzdem, bis der aktuelle synchrone Code durch ist. awaitinnerhalb einer Schleife serialisiert die Arbeit, weil jede Iteration erst die Microtask Queue durchlaufen muss, bevor es weitergeht.
Asynchronen Code zu debuggen läuft im Kern auf die Frage hinaus: "Was liegt gerade auf dem Stack, und was steht in der Queue?" Die Antwort liefert dir der Event Loop.
Weiter geht's: Callbacks
Vor Promises und async/await hatte JavaScript für asynchrone Arbeit nur ein einziges Werkzeug: den Callback — also eine Funktion, die du einer API übergibst, damit sie später aufgerufen wird. Callbacks sind nach wie vor überall zu finden (Event Listener, die Kern-APIs von Node), und sie zu verstehen ist die Grundlage für alles Weitere in diesem Kapitel.
Häufig gestellte Fragen
Was ist der Event Loop in JavaScript?
Der Event Loop ist der Mechanismus, mit dem Single-Threaded JavaScript asynchrone Arbeit erledigt, ohne den Haupt-Thread zu blockieren. Er beobachtet den Call Stack — sobald der leer ist, holt er den nächsten Callback aus der Queue und führt ihn aus. Timer, I/O und Promise-Fortsetzungen landen alle in Queues, die der Event Loop nach und nach abarbeitet.
Warum ist JavaScript single-threaded?
Die Sprachspezifikation sieht genau einen Call Stack pro Realm vor — dein Code läuft also auf genau einem Thread. Parallelität entsteht über den Host (Browser oder Node): Der reicht Aufgaben an Hintergrund-APIs weiter (Timer, Netzwerk, Datei-I/O) und legt einen Callback in die Queue, sobald sie fertig sind. Im selben Kontext laufen aber nie zwei JS-Schnipsel gleichzeitig.
Was ist der Unterschied zwischen Microtasks und Macrotasks?
Microtasks kommen von Promises (.then, await) und queueMicrotask. Macrotasks entstehen durch setTimeout, setInterval, I/O und UI-Events. Nach jedem abgeschlossenen Macrotask leert der Event Loop die komplette Microtask Queue, bevor der nächste Macrotask dran ist. Genau deshalb läuft ein Promise.resolve().then(...) immer vor einem gleichzeitig geplanten setTimeout(..., 0).
Warum wird setTimeout mit 0ms nicht sofort ausgeführt?
setTimeout(fn, 0) heißt nicht „führ das jetzt aus“, sondern „schieb fn als Macrotask in die Queue, frühestens nach 0ms“. Erst muss der laufende synchrone Code fertig werden, dann die Microtask Queue leer sein — und dann greift sich der Event Loop deinen Timer-Callback. Die 0 ist also eine Untergrenze, keine Garantie.