ReactAnimationen

Core Web Vitals: was INP wirklich misst, und wie scheduler.yield() den Hauptthread rettet

Core Web Vitals: was INP wirklich misst, und wie scheduler.yield() den Hauptthread rettet

Im Artikel über Mikrointeraktionen habe ich gezeigt, warum transform und opacity selbst auf einem schwachen Smartphone flüssig animieren – weil der Browser sie ausschließlich in der Kompositionsphase behandelt, auf einem separaten GPU-Thread, ohne das Layout anzufassen. Das stimmt weiterhin, dahinter verbirgt sich aber eine Annahme, die es sich lohnt, jetzt offenzulegen: Damit diese Animation überhaupt startet, muss der Browser zunächst das Klick-Ereignis auf dem Hauptthread verarbeiten – demselben Thread, auf dem dein gesamter JavaScript-Code läuft. Ist der Hauptthread im Moment des Klicks mit der Ausführung einer langen, synchronen Aufgabe beschäftigt – dem Filtern eines großen Arrays, dem Parsen einer API-Antwort, dem Rendern eines komplexen Komponentenbaums –, wird das Klick-Ereignis keinen Augenblick früher behandelt, als diese Aufgabe zu Ende ist. Die Animation selbst kann so billig wie nur irgendetwas sein. Das spielt keine Rolle, wenn niemand sie rechtzeitig starten konnte.

Interaction to Next Paint (INP) ist eine Core-Web-Vitals-Metrik, die genau diese Lücke misst – nicht die Zeit bis zum ersten Byte, nicht die Ladezeit der Seite, sondern die Zeit zwischen dem Moment, in dem der Nutzer irgendetwas betätigt, und dem Moment, in dem der Browser den Effekt dieser Interaktion tatsächlich auf dem Bildschirm einmalt. Sie hat im März 2024 die ältere Metrik FID (First Input Delay) aus einem gewichtigen Grund abgelöst: FID maß nur die erste Interaktion auf der Seite, sodass eine Seite ein hervorragendes Ergebnis haben konnte, selbst wenn jeder weitere Klick nach dem ersten eine Sekunde lang stockte. INP berücksichtigt alle Interaktionen im gesamten Lebenszyklus der Seite und meldet in der Praxis den schlechtesten, sich wiederholenden Fall – man kann es nicht durch einen guten ersten Eindruck „reparieren".

Warum sich JavaScript nicht mittendrin unterbrechen lässt

Der entscheidende Mechanismus, ohne den INP keinen Sinn ergibt: JavaScript im Browser läuft nach dem Modell run-to-completion – eine einmal gestartete Aufgabe läuft von Anfang bis Ende durch, ohne Unterbrechung, unabhängig davon, was der Nutzer in der Zwischenzeit versucht. Ein Klick während einer solchen Aufgabe wird nicht ignoriert – er landet in der Warteschlange und wartet, bis der Thread frei wird. Eine Aufgabe, die länger als 50 ms dauert, wird formal als Long Task klassifiziert (Long Tasks API) – das ist keine willkürliche Zahl, sondern eine Annäherung an die Grenze, oberhalb derer der Nutzer subjektiv zu spüren beginnt, dass etwas „hängt", ähnlich im Geiste der Doherty-Schwelle aus dem Artikel über Mikrointeraktionen, nur gemessen auf Seiten der Rechenkosten statt der Netzwerkkosten.

INP zerfällt in drei Komponenten, und lange Aufgaben blähen meist die erste davon auf:

  • Input-Verzögerung (input delay) – die Zeit vom Klick bis zu dem Moment, in dem der Hauptthread überhaupt beginnt, das Ereignis zu verarbeiten. Genau hier blockiert eine lange Aufgabe alles.
  • Verarbeitungszeit (processing time) – wie lange die Ausführung deines Event-Handlers tatsächlich dauert.
  • Darstellungsverzögerung (presentation delay) – die Zeit vom Ende des Handlers bis zum tatsächlichen Einmalen des Frames mit dem Effekt.

Yielding: wie man dem Browser mitten in der Arbeit den Thread zurückgibt

Da sich eine Aufgabe nicht von außen unterbrechen lässt, bleibt nur, sie bewusst von innen zu unterbrechen – die lange, synchrone Arbeit in kleinere Häppchen zu zerlegen und dazwischen die Kontrolle an den Browser abzugeben, damit er es schafft, ausstehende Eingaben zu verarbeiten und einen Frame einzumalen, bevor du das nächste Stück Arbeit übernimmst.

javascript

async function processInChunks(items, processItem) {
const results = [];
for (let i = 0; i < items.length; i++) {
results.push(processItem(items[i]));
if (i % 50 === 0) {
if ("scheduler" in window && "yield" in scheduler) {
await scheduler.yield();
} else {
// Fallback: setTimeout(fn, 0), NICHT Promise.resolve().then()
await new Promise((resolve) => setTimeout(resolve, 0));
}
}
}
return results;
}

Ein Detail, das leicht übersehen wird und den Effekt dieser Technik in der Praxis zunichtemacht: await Promise.resolve() oder queueMicrotask() geben dem Browser nicht die Kontrolle zurück. Das sind Microtasks – die Microtask-Warteschlange wird vollständig geleert, bevor der Browser überhaupt in Erwägung zieht, eine ausstehende Eingabe zu verarbeiten oder einen Frame einzumalen. Wenn du in einer asynchronen Schleife ausschließlich Microtasks nutzt, um Arbeit „aufzuteilen", ist das aus Sicht des Browsers trotzdem eine einzige, durchgehende Aufgabe – nur eben in then()-Aufrufe statt in einfache Codezeilen zerlegt. Um den Thread tatsächlich zurückzugeben, brauchst du eine Macrotask-Grenze – setTimeout, MessageChannel (schneller als setTimeout(0), weil es die von Browsern erzwungene Mindestverzögerung bei verschachtelten Timeouts umgeht) oder das neue, eigens dafür geschaffene scheduler.yield() aus der Scheduler API, das die Fortsetzung zusätzlich intelligent gegenüber anderen wartenden Aufgaben priorisiert, statt wie setTimeout einfach ans Ende der normalen Warteschlange zu wandern.

isInputPending(): Gib den Thread nicht ab, wenn niemand darauf wartet

Die Kontrolle nach jedem einzelnen Schleifendurchlauf abzugeben, hat ihren Preis – jeder Durchlauf durch die Ereignisschleife ist Overhead. Hat gerade niemand irgendetwas geklickt, ist ein Abbruch der Arbeit alle 50 Iterationen reine Zeitverschwendung, die die Gesamtverarbeitungszeit verlängert, ohne dem Nutzer irgendeinen Vorteil zu bringen. navigator.scheduling.isInputPending() löst das mit einer direkten Frage: Wartet in der Warteschlange tatsächlich ein unverarbeitetes Eingabeereignis, bevor du dich entscheidest, die Arbeit zu unterbrechen.

javascript

function processQueue(tasks) {
while (tasks.length > 0) {
if (navigator.scheduling?.isInputPending()) {
break; // jemand wartet tatsächlich – gib den Thread jetzt ab
}
doExpensiveWork(tasks.shift());
}
if (tasks.length > 0) {
setTimeout(() => processQueue(tasks), 0);
}
}

Das kehrt die Logik gegenüber dem starren „alle 50 Elemente" um: Statt im Voraus zu raten, wie viel Arbeit zwischen zwei Yields „sicher" ist, fragst du den Browser laufend, ob dafür gerade Bedarf besteht. Bei leerer Eingabe-Warteschlange kann die Schleife deutlich länger als 50 Iterationen ohne Unterbrechung weiterlaufen – bei einem echten Klick während der Verarbeitung gibt sie die Kontrolle sofort ab, genau dort, wo es zählt.

Fallstricke und Best Practices

  • Microtasks zählen nicht als Yield. Das ist der häufigste Fehler beim Versuch, INP zu „reparieren" – der Code sieht asynchron aus, weil er ein await hat, aber wenn hinter diesem await nur Promise.resolve() steckt, behandelt der Browser das Ganze weiterhin als eine einzige, ununterbrechbare Aufgabe.
  • scheduler.yield() braucht weiterhin einen Fallback. Die Unterstützung beschränkt sich vorwiegend auf Chromium-basierte Browser – behandle es als progressive Verbesserung mit setTimeout als sicherer Rückfalloption, genau wie die Paint API im Artikel über CSS Houdini.
  • Lange Aufgaben aus Drittanbieter-Skripten zählen ebenfalls zu deinem INP-Wert. Analytics, Werbung, Chat-Widgets – sie leben auf demselben Hauptthread und blockieren ihn genauso wie dein eigener Code. Ein PerformanceObserver, der auf Einträge vom Typ longtask lauscht, zeigt sie alle, unabhängig davon, wer sie verursacht hat – es lohnt sich, echten Produktivverkehr zu messen (RUM), nicht nur lokale Tests auf einer leeren, gecachten Entwicklungsumgebung.
  • INP misst den schlechtesten, sich wiederholenden Fall im gesamten Lebenszyklus der Seite, nicht den ersten Eindruck. Eine Interaktion, die erst nach zehn Minuten Nutzung der Anwendung stockt, wenn im Client-State schon viele Daten angesammelt sind, zählt weiterhin zum Ergebnis – nur die ersten paar Klicks auf einer frisch geladenen Seite zu testen, unterschätzt das reale Problem systematisch.

Fazit

Der Artikel über Mikrointeraktionen zeigte, wie man dafür sorgt, dass die Animation selbst den Hauptthread nichts kostet. INP zeigt die andere Hälfte derselben Gleichung: Selbst die günstigste Animation der Welt hilft nichts, wenn der Hauptthread genau in dem Moment, in dem der Nutzer klickt, mit einer langen, ununterbrechbaren JavaScript-Aufgabe beschäftigt ist. Das Run-to-completion-Modell macht bewusstes Zerlegen der eigenen, schweren Arbeit in Häppchen und das dazwischenliegende Abgeben der Kontrolle an den Browser zum einzigen Weg zur Reaktionsfähigkeit – durch eine echte Macrotask, nicht eine Microtask, und idealerweise dann, wenn isInputPending() tatsächlich bestätigt, dass jemand darauf wartet. Die gesamte Kette der gefühlten Performance, von der Doherty-Schwelle bis zu INP, ist nur so stark wie ihr schwächstes Glied – und dieses Glied erweist sich immer häufiger nicht als Netzwerk oder Server, sondern als der eigene, zu lange nicht aufgeteilte JavaScript-Code.