reactanimaties

Core Web Vitals: wat INP écht meet, en hoe scheduler.yield() de hoofdthread redt

Core Web Vitals: wat INP écht meet, en hoe scheduler.yield() de hoofdthread redt

In het artikel over micro-interacties liet ik zien waarom transform en opacity soepel animeren, zelfs op een zwakke telefoon – omdat de browser ze uitsluitend afhandelt in de compositiefase, op een aparte GPU-thread, zonder de layout aan te raken. Dat klopt nog steeds, maar er schuilt een aanname achter die het nu tijd is om bloot te leggen: om die animatie überhaupt te laten starten, moet de browser eerst het klikevenement afhandelen op de hoofdthread – dezelfde thread waarop al je JavaScript wordt uitgevoerd. Als de hoofdthread op het moment van de klik bezig is met een lange, synchrone taak – het filteren van een grote array, het parsen van een API-respons, het renderen van een complexe componentenboom – wordt het klikevenement geen moment eerder afgehandeld dan wanneer die taak klaar is. De animatie zelf kan zo goedkoop zijn als wat. Dat maakt niets uit als niemand hem op tijd heeft kunnen starten.

Interaction to Next Paint (INP) is de Core Web Vitals-metriek die precies dat gat meet – niet de tijd tot de eerste byte, niet de laadtijd van de pagina, maar de tijd tussen het moment dat een gebruiker ergens op drukt en het moment dat de browser het effect van die interactie daadwerkelijk op het scherm schildert. Hij verving de oudere metriek FID (First Input Delay) in maart 2024 om één belangrijke reden: FID mat alleen de eerste interactie op een pagina, dus een pagina kon een uitstekende score halen, zelfs als elke volgende klik na de eerste een seconde hapertte. INP houdt rekening met alle interacties gedurende de hele levenscyclus van de pagina en rapporteert in de praktijk het slechtste, herhaalbare geval – je kunt het niet "repareren" met een goede eerste indruk.

Waarom JavaScript zich niet halverwege laat onderbreken

Het cruciale mechanisme waar INP zonder geen betekenis heeft: JavaScript in de browser werkt volgens het run-to-completion-model – eenmaal gestart, voert een taak zich van begin tot eind uit, zonder onderbreking, ongeacht wat een gebruiker ondertussen probeert te doen. Een klik tijdens zo'n taak wordt niet genegeerd – hij belandt in een wachtrij en wacht tot de thread vrijkomt. Een taak die langer dan 50 ms duurt, wordt formeel geclassificeerd als een long task (Long Tasks API) – geen arbitrair getal, maar een benadering van de grens waarboven een gebruiker subjectief begint te voelen dat er iets "vastzit", vergelijkbaar in geest met de Doherty-drempel uit het artikel over micro-interacties, alleen hier gemeten aan de kant van rekenkosten in plaats van netwerkkosten.

INP valt uiteen in drie componenten, en lange taken laten meestal vooral de eerste opzwellen:

  • Invoervertraging (input delay) – de tijd van de klik tot het moment waarop de hoofdthread het evenement überhaupt begint af te handelen. Precies hier blokkeert een lange taak alles.
  • Verwerkingstijd (processing time) – hoe lang de uitvoering van je eigen event-handler daadwerkelijk duurt.
  • Presentatievertraging (presentation delay) – de tijd tussen het einde van de handler en het daadwerkelijk schilderen van de frame met het effect.

Yielding: hoe je de thread halverwege het werk teruggeeft aan de browser

Omdat een taak zich niet van buitenaf laat onderbreken, is de enige uitweg om hem bewust van binnenuit te onderbreken – lang, synchroon werk opsplitsen in kleinere stukken en tussendoor de controle teruggeven aan de browser, zodat hij tijd heeft om achterstallige invoer af te handelen en een frame te schilderen voordat je het volgende stukje werk oppakt.

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), NIET Promise.resolve().then()
await new Promise((resolve) => setTimeout(resolve, 0));
}
}
}
return results;
}

Een detail dat makkelijk over het hoofd wordt gezien en dat het effect van deze techniek écht ondermijnt: await Promise.resolve() of queueMicrotask() geeft de controle niet terug aan de browser. Dit zijn microtaken (microtasks) – de wachtrij van microtaken wordt volledig leeggemaakt voordat de browser er überhaupt over nadenkt om achterstallige invoer af te handelen of een frame te schilderen. Als je in een asynchrone lus uitsluitend microtaken gebruikt om werk te "verdelen", is dat vanuit het perspectief van de browser nog steeds één, aaneengesloten taak – alleen opgesplitst in then()-aanroepen in plaats van simpele regels code. Om de thread écht terug te geven, heb je een macrotaak-grens nodig – setTimeout, MessageChannel (sneller dan setTimeout(0), omdat het de minimale vertraging omzeilt die browsers opleggen aan geneste timeouts) of het nieuwe, hiervoor speciaal ontworpen scheduler.yield() uit de Scheduler API, dat de voortzetting bovendien intelligent prioriteert ten opzichte van andere wachtende taken, in plaats van achteraan een gewone wachtrij te belanden zoals setTimeout.

isInputPending(): geef de thread niet terug als niemand erop wacht

Controle teruggeven na elk afzonderlijk element van een lus heeft zijn eigen kosten – elke doorgang van de event loop brengt overhead met zich mee. Als er op dat moment niemand iets heeft aangeklikt, is het werk elke 50 iteraties onderbreken pure tijdverspilling, die de totale verwerkingstijd verlengt zonder enig voordeel voor de gebruiker. navigator.scheduling.isInputPending() lost dit op door de vraag rechtstreeks te stellen: staat er daadwerkelijk een niet-afgehandeld invoerevenement in de wachtrij, voordat je besluit het werk te onderbreken.

javascript

function processQueue(tasks) {
while (tasks.length > 0) {
if (navigator.scheduling?.isInputPending()) {
break; // iemand wacht daadwerkelijk – geef de thread nu terug
}
doExpensiveWork(tasks.shift());
}
if (tasks.length > 0) {
setTimeout(() => processQueue(tasks), 0);
}
}

Dit keert de logica om ten opzichte van een star "om de 50 elementen": in plaats van vooraf te gokken hoeveel werk "veilig" is tussen de ene yield en de volgende, vraag je de browser voortdurend of daar op dit moment behoefte aan is. Bij een lege invoerwachtrij kan de lus veel langer dan 50 iteraties doorgaan zonder onderbreking – bij een echte klik tijdens het verwerken geeft hij de controle onmiddellijk terug, precies waar dat ertoe doet.

Valkuilen en goede praktijken

  • Microtaken tellen niet als yield. Dit is de meest voorkomende fout bij het proberen "repareren" van INP – de code ziet er asynchroon uit omdat er een await staat, maar als achter die await alleen een Promise.resolve() schuilt, behandelt de browser het geheel nog steeds als één ononderbreekbare taak.
  • scheduler.yield() heeft nog steeds een fallback nodig. De ondersteuning is grotendeels beperkt tot op Chromium gebaseerde browsers – behandel het als progressive enhancement met setTimeout als veilige terugvalvariant, precies zoals de Paint API in het artikel over CSS Houdini.
  • Lange taken uit scripts van derden tellen ook mee voor je INP-score. Analytics, advertenties, chatwidgets – ze leven op dezelfde hoofdthread en blokkeren hem net zo goed als je eigen code. Een PerformanceObserver die luistert naar entries van het type longtask toont ze allemaal, ongeacht wie ze heeft veroorzaakt – het loont om echt productieverkeer te meten (RUM), niet alleen lokale tests op een lege, gecachte ontwikkelomgeving.
  • INP meet het slechtste herhaalbare geval over de hele levenscyclus van de pagina, niet de eerste indruk. Een interactie die pas na tien minuten gebruik van de applicatie hapert, wanneer er in de clientstate veel data is opgebouwd, telt nog steeds mee voor de score – alleen de eerste paar klikken op een net geladen pagina testen onderschat het echte probleem systematisch.

Samenvatting

Het artikel over micro-interacties liet zien hoe je ervoor zorgt dat de animatie zelf de hoofdthread niets kost. INP laat de andere helft van diezelfde vergelijking zien: zelfs de goedkoopste animatie ter wereld helpt niet als de hoofdthread bezet is door een lange, ononderbreekbare JavaScript-taak op het moment dat de gebruiker klikt. Het run-to-completion-model maakt bewust opsplitsen van je eigen zware werk in stukken, en tussendoor de controle teruggeven aan de browser, tot de enige manier om responsief te blijven – via een echte macrotaak, geen microtaak, en het liefst pas wanneer isInputPending() daadwerkelijk bevestigt dat iemand erop wacht. De hele keten van waargenomen performance, van de Doherty-drempel tot INP, is zo sterk als zijn zwakste schakel – en die schakel blijkt steeds vaker niet het netwerk of de server te zijn, maar je eigen, te lang niet opgesplitste JavaScript.