Zurück zum Blog
Juni 2026·3 Min. LesezeitKI-generiert
Sprache:DeutschEnglishFrançaisÖsterreichischKlingonisch Originalfassung: English

Die JavaScript-Event-Loop verstehen

#javascript#async#performance

JavaScript läuft auf einem einzigen Thread und bewältigt trotzdem tausende gleichzeitige Netzwerkanfragen, Timer und UI-Events, ohne zu blockieren. Die Event-Loop ist der Mechanismus, der das möglich macht, und sobald man ihre Reihenfolge vorhersagen kann, hören ganze Kategorien von „warum lief das vor jenem?"-Bugs auf, rätselhaft zu sein.

Ein Thread, ein Call Stack

JavaScript kann nur ein Stück Code gleichzeitig ausführen. Jeder Funktionsaufruf legt einen Frame auf den Call Stack, und der Stack muss sich leeren, bevor die Engine sich etwas anderes ansieht. Das ist der Teil, den die meisten schon kennen. Was Leute zum Stolpern bringt, ist alles, was um den Stack herum passiert: Timer, Promises, I/O-Callbacks und DOM-Events laufen nicht sofort, wenn sie bereit sind – sie werden eingereiht, und die Event-Loop entscheidet, wann sie aus der Warteschlange geholt und ausgeführt werden.

console.log('1');

setTimeout(() => console.log('2'), 0);

Promise.resolve().then(() => console.log('3'));

console.log('4');

Das gibt 1, 4, 3, 2 aus – nicht 1, 2, 3, 4. Um zu verstehen, warum, muss man wissen, dass es nicht eine Warteschlange gibt, sondern (mindestens) zwei.

Zwei Warteschlangen, nicht eine

Macrotasks (auch „Task Queue" genannt) umfassen setTimeout, setInterval, I/O-Callbacks und UI-Events. Microtasks umfassen Promise-Callbacks (.then, .catch, .finally) und queueMicrotask.

Die Regel der Event-Loop: Nachdem das aktuell laufende Skript beendet ist und der Call Stack leer ist, wird die gesamte Microtask-Warteschlange abgearbeitet – einschließlich neuer Microtasks, die währenddessen hinzukommen –, bevor auch nur ein Macrotask läuft.

Deshalb wurde oben 3 vor 2 ausgegeben: Promise.resolve().then(...) reiht einen Microtask ein, setTimeout(..., 0) einen Macrotask, und Microtasks gewinnen immer, egal wie klein die Verzögerung des Macrotasks ist.

function logOrder() {
  console.log('start');

  setTimeout(() => console.log('macrotask'), 0);

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

  console.log('end');
}

logOrder();
// start
// end
// microtask 1
// microtask 2
// macrotask

Beide verketteten .then()-Aufrufe laufen vor dem setTimeout-Callback, obwohl dieser mit 0 ms Verzögerung geplant wurde – die Microtask-Warteschlange wird zuerst vollständig geleert.

Warum setTimeout(fn, 0) nicht „sofort" bedeutet

Eine Verzögerung von 0 bedeutet nicht „sofort ausführen". Sie bedeutet „so bald wie möglich, aber erst wenn der aktuelle Call Stack leer und die Microtask-Warteschlange abgearbeitet ist". Browser begrenzen außerdem verschachtelte Timeouts nach einigen Verschachtelungsebenen auf mindestens 4 ms. Wenn du setTimeout(fn, 0) nutzt, um Arbeit zu „verschieben", ist das in Ordnung – erwarte nur nicht, dass es sich mit synchronem oder Microtask-Code verzahnt.

Ein praktischer Fall: warum dein UI-Update zu spät kommt

function expensiveWork() {
  let sum = 0;
  for (let i = 0; i < 1e9; i++) sum += i;
  return sum;
}

showLoadingSpinner(); // updates the DOM
const result = expensiveWork(); // blocks the thread
hideLoadingSpinner();

Der Spinner erscheint nie. showLoadingSpinner() plant ein DOM-Update, aber der Browser kann erst malen, wenn der Call Stack leer ist – und expensiveWork() hält den Stack die ganze Zeit beschäftigt. Die Lösung ist, vor der schweren Arbeit zur Event-Loop zurückzukehren, damit der Browser Gelegenheit zum Malen bekommt:

function showSpinnerThenWork() {
  showLoadingSpinner();

  setTimeout(() => {
    const result = expensiveWork();
    hideLoadingSpinner();
    renderResult(result);
  }, 0);
}

Jetzt gelangt das Spinner-Update in einen Paint, bevor expensiveWork() startet. Für wirklich langlaufende Arbeit ist die bessere Lösung, sie mit einem Web Worker ganz vom Hauptthread zu nehmen – setTimeout(fn, 0) hilft nur für den ersten Paint, es hindert die Schleife nicht daran, erneut zu blockieren, sobald expensiveWork() läuft.

async/await ist Microtask-Zucker

async-Funktionen führen keine neuen Nebenläufigkeits-Primitive ein – sie bauen vollständig auf Promises und der Microtask-Warteschlange auf. Jedes await hält die Funktion an und reiht ihre Fortsetzung als Microtask ein, sobald der erwartete Wert aufgelöst ist.

async function sequence() {
  console.log('a');
  await null;
  console.log('b');
}

console.log('before');
sequence();
console.log('after');

// before
// a
// after
// b

await null gibt trotzdem an die Microtask-Warteschlange ab, obwohl null bereits ein „aufgelöster" Wert ist – die Fortsetzung nach await läuft nie synchron, egal worauf man wartet.

Die Event-Loop mit Microtasks aushungern

Weil die Microtask-Warteschlange vollständig geleert wird, bevor der nächste Macrotask läuft, kann ein Microtask, der immer weiter Microtasks einreiht, Rendering und I/O unbegrenzt aushungern:

function recurse() {
  Promise.resolve().then(recurse);
}
recurse(); // freezes the tab — the loop never reaches a macrotask

Das ist eine echte Falle in rekursiven Promise-Ketten – ein .then()-Callback, der sich bedingungslos selbst neu einreiht, legt die Seite lahm, während das Äquivalent mit setTimeout dem Browser zwischen den Iterationen wenigstens Luft zum Atmen lassen würde.

Fazit

Die Reihenfolge der Event-Loop ist nicht willkürlich: Zuerst läuft synchroner Code, dann wird die gesamte Microtask-Warteschlange geleert, dann läuft ein Macrotask, dann wird die Microtask-Warteschlange wieder geleert, und so weiter. Promises und async/await leben auf der Microtask-Spur; Timer, I/O und UI-Events auf der Macrotask-Spur. Weiß man, auf welcher Spur ein Stück Code liegt, kann man vorhersagen, in welcher Reihenfolge es läuft – kein Raten mehr.

NeuerDeine erste GitHub Action schreibenÄlterMemory Leaks in Node.js debuggen