Der Event Loop in JavaScript: Warum dein Code in dieser Reihenfolge läuft

JavaScript ist single-threaded – und trotzdem blockiert ein Timer nicht die ganze Seite. Wir schauen uns Call Stack, Task Queue und Microtasks an und klären, warum Promises immer vor setTimeout drankommen.

Teilen

Du hast bestimmt schon einmal Code geschrieben, bei dem die Ausgaben in einer Reihenfolge kamen, die du nicht erwartet hast. Ein console.log nach einem setTimeout erscheint plötzlich zuerst, und eine Promise-Auflösung überholt einen Timer mit 0 Millisekunden. Der Grund dafür heißt Event Loop. Wenn du ihn einmal verstanden hast, wird asynchroner JavaScript-Code von Magie zu Mechanik – und Bugs, die vorher unerklärlich waren, lösen sich fast von selbst.

JavaScript ist single-threaded

JavaScript führt deinen Code in genau einem Thread aus. Es gibt zu jedem Zeitpunkt nur eine Sache, die gerade läuft. Das klingt nach einer harten Einschränkung – wie kann eine Seite dann gleichzeitig auf Klicks reagieren, Daten laden und animieren?

Die Antwort: Die eigentlich langsamen Dinge macht nicht JavaScript. Netzwerkanfragen, Timer und das Warten auf Klicks übernimmt die Umgebung – der Browser oder Node.js. JavaScript sagt nur „sag mir Bescheid, wenn du fertig bist" und arbeitet in der Zwischenzeit weiter.

Warum das wichtig ist, zeigt dieses Beispiel:

function blockiere(ms) {
  const ende = Date.now() + ms;
  while (Date.now() < ende) {
    // beschäftigt warten – nichts anderes kann laufen
  }
}

console.log("Start");
blockiere(3000);
console.log("Nach 3 Sekunden");

Während dieser Schleife friert die gesamte Seite ein. Keine Klicks, keine Animationen, kein Scrollen. Genau deshalb darfst du den Thread niemals lange blockieren.

Call Stack, Web-APIs und die Task Queue

Drei Bausteine reichen aus, um das Verhalten zu erklären:

  • Call Stack – der Stapel gerade laufender Funktionsaufrufe. Ganz oben liegt das, was aktuell ausgeführt wird.
  • Web-APIs – Timer, fetch, Event-Listener. Sie laufen außerhalb von JavaScript in der Umgebung.
  • Task Queue (auch Callback Queue) – hier landen fertige Callbacks und warten darauf, drankommen zu dürfen.

Der Event Loop selbst ist erstaunlich simpel. Er prüft ununterbrochen: Ist der Call Stack leer? Wenn ja, nimmt er den ersten Eintrag aus der Queue und legt ihn auf den Stack. Wenn nein, wartet er. Mehr passiert nicht.

console.log("1");

setTimeout(() => {
  console.log("2");
}, 0);

console.log("3");

// Ausgabe: 1, 3, 2

Die 0 bedeutet nicht „sofort", sondern „so früh wie möglich, aber erst wenn der Stack leer ist". Der synchrone Code läuft immer komplett durch, bevor irgendein Callback drankommt.

Microtasks überholen Makrotasks

Jetzt kommt der Teil, der die meisten Überraschungen erzeugt: Es gibt nicht eine Queue, sondern zwei mit unterschiedlicher Priorität.

  • Makrotasks: setTimeout, setInterval, I/O-Events, Klick-Handler.
  • Microtasks: .then()-Callbacks von Promises, await-Fortsetzungen, queueMicrotask().

Die Regel lautet: Nach jedem Makrotask arbeitet der Event Loop die komplette Microtask-Queue ab, bevor der nächste Makrotask an die Reihe kommt.

console.log("Skript-Start");

setTimeout(() => console.log("Timeout"), 0);

Promise.resolve().then(() => console.log("Promise 1"))
                 .then(() => console.log("Promise 2"));

console.log("Skript-Ende");

// Ausgabe:
// Skript-Start
// Skript-Ende
// Promise 1
// Promise 2
// Timeout

Obwohl der Timer zuerst registriert wurde, kommen beide Promise-Callbacks davor. Das ist kein Zufall, sondern garantiertes Verhalten der Spezifikation – du kannst dich darauf verlassen.

Ein praktischer Hinweis: Eine Microtask, die immer wieder neue Microtasks erzeugt, kann den Event Loop komplett aushungern. Timer und Rendering kommen dann nie mehr dran:

// Achtung: blockiert die Seite dauerhaft
function endlos() {
  Promise.resolve().then(endlos);
}

Was async/await damit zu tun hat

async und await sind syntaktischer Zucker über Promises – und damit über Microtasks. Alles vor dem ersten await läuft synchron, alles danach wird zur Microtask.

async function laden() {
  console.log("A: läuft synchron");
  await null;               // ab hier: Microtask
  console.log("C: nach await");
}

laden();
console.log("B: nach dem Aufruf");

// Ausgabe: A, B, C

Diese Erkenntnis erspart dir viel Rätselraten: Ein await unterbricht deine Funktion und gibt die Kontrolle zurück an den aufrufenden Code. Erst wenn der Stack leer ist, geht es weiter.

Konsequenzen für deinen Alltag

Aus dem Modell folgen ein paar sehr konkrete Regeln:

  • Rechne nie lange synchron. Zerlege große Schleifen in Häppchen oder lagere sie in einen Web Worker aus.
  • setTimeout(fn, 100) ist eine Untergrenze, keine Garantie. Ist der Stack belegt, wird es später.
  • Reihenfolge über Promises steuern, nicht über Timer. setTimeout(..., 0) als „Trick" ist meist ein Symptom für ein Design-Problem.
  • Rendering passiert zwischen Makrotasks. Wenn die UI nicht aktualisiert wird, blockierst du wahrscheinlich zu lange.

Ein typisches Muster, um eine lange Berechnung aufzubrechen:

async function verarbeiteGross(daten) {
  for (let i = 0; i < daten.length; i++) {
    verarbeite(daten[i]);

    // alle 500 Elemente dem Browser Luft zum Atmen geben
    if (i % 500 === 0) {
      await new Promise(r => setTimeout(r, 0));
    }
  }
}

Das await auf einen Timer gibt die Kontrolle kurz zurück – der Browser kann neu zeichnen und auf Eingaben reagieren, bevor es weitergeht.

Fazit

Der Event Loop ist kein exotisches Detail für Fortgeschrittene, sondern das Herzstück davon, wie JavaScript überhaupt funktioniert. Merk dir die Kernpunkte: Synchroner Code läuft immer zuerst komplett durch, danach die gesamte Microtask-Queue (Promises, await), und erst dann ein einzelner Makrotask (setTimeout, Events). Wenn du das nächste Mal über eine seltsame Ausführungsreihenfolge stolperst, geh diese drei Ebenen im Kopf durch – in den allermeisten Fällen erklärt sich das Verhalten sofort. Und wenn du gerade ein Projekt vor dir hast: Bau die Beispiele aus diesem Beitrag einmal selbst in der Browser-Konsole nach. Nichts festigt das Modell so gut wie eine Ausgabe, die du vorher richtig vorhergesagt hast.