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.
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');
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);
}