Events und Timing · 2 Min. Lesezeit

Tasks, Microtasks und requestAnimationFrame: wer wann drankommt

Die Event Loop ist kein Geheimnis, aber ihre Reihenfolge erklärt viele seltsame Bugs – und warum ein Promise die Seite einfrieren kann.

JavaScript läuft im Browser auf einem einzigen Thread. Was als Nächstes passiert, entscheidet die Event Loop: Sie nimmt einen Task aus einer Warteschlange, führt ihn bis zum Ende aus, arbeitet alle Microtasks ab und gibt dem Browser dann, wenn es Zeit für ein neues Bild ist, Gelegenheit zum Rendern.

Eine Runde der Event Loop: ein Task, dann alle Microtasks, dann – wenn ein neues Bild fällig ist – requestAnimationFrame und das Rendern. Bei 60 Hz ist das etwa alle 16,7 ms der Fall, viele Runden kommen also ganz ohne Rendern aus.

Ein kleines Experiment

console.log('1: synchron');
setTimeout(() => console.log('5: setTimeout'), 0);
Promise.resolve().then(() => console.log('3: Promise'));
queueMicrotask(() => console.log('4: queueMicrotask'));
console.log('2: synchron');
Die Ausgabe in der Konsole, in genau dieser Reihenfolge – in jedem Browser.

Zuerst läuft der gesamte synchrone Code, denn er gehört zum aktuellen Task. Danach leert die Event Loop die Microtask-Queue vollständig – Promise-Callbacks und queueMicrotask in der Reihenfolge, in der sie eingereiht wurden. Erst dann kommt der nächste Task an die Reihe, hier der Timer.

Und requestAnimationFrame?

requestAnimationFrame gehört nicht zu den Tasks, sondern zum Rendering: Der Callback läuft unmittelbar vor dem nächsten Style- und Layout-Schritt. Ob das vor oder nach einem setTimeout(…, 0) passiert, hängt davon ab, wann der Browser das nächste Bild zeichnen will – mal so, mal so. Wer sich auf diese Reihenfolge verlässt, hat eine Race Condition.

Daraus folgt eine nützliche Regel: Alles, was die Darstellung ändert, gehört in requestAnimationFrame, alles andere nicht. In einem Tab im Hintergrund wird rAF übrigens gar nicht aufgerufen.

Wenn Microtasks nie enden

Weil die Microtask-Queue vollständig geleert wird, bevor irgendetwas anderes passiert, kann sie die Seite einfrieren:

function again() {
  queueMicrotask(again); // reiht sich immer wieder selbst ein
}
again(); // kein Rendern, kein Klick, nichts mehr

Mit setTimeout statt queueMicrotask wäre dieselbe Schleife harmlos, weil der Browser zwischen zwei Tasks rendern und auf Eingaben reagieren kann. Dasselbe passiert subtiler mit langen Ketten von await auf bereits erfüllte Promises: Sie fühlen sich asynchron an, geben den Main Thread aber nie frei.

Timer sind ungenau

setTimeout(fn, 0) heißt nicht »sofort«, sondern »als eigener Task, frühestens jetzt«. Bei tief verschachtelten Timern erzwingt der Browser mindestens 4 ms Abstand, und in Hintergrund-Tabs laufen Timer oft nur noch einmal pro Sekunde oder seltener. Um kurz den Main Thread freizugeben, ist scheduler.yield() die bessere Wahl, wo es verfügbar ist (siehe Lange Tasks finden, bevor es die Nutzer tun); für Arbeit, die warten kann, gibt es scheduler.postTask() mit Priorität:

if (globalThis.scheduler?.postTask) {
  scheduler.postTask(sendStats, { priority: 'background' }); // darf warten
} else {
  setTimeout(sendStats, 0);
}

← Alle Notizen