Core Web Vitals: hvad INP egentlig måler, og hvordan scheduler.yield() redder hovedtråden
Core Web Vitals: hvad INP egentlig måler, og hvordan scheduler.yield() redder hovedtråden
I artiklen om mikrointeraktioner viste jeg, hvorfor transform og opacity animeres flydende selv på en svag telefon – fordi browseren udelukkende håndterer dem i kompositeringsfasen, på en separat GPU-tråd, uden at røre layoutet. Det er stadig sandt, men bag det gemmer der sig en antagelse, det nu er værd at afsløre: for at den animation overhovedet kan starte, skal browseren først håndtere klik-hændelsen på hovedtråden – den samme tråd, hvor al din JavaScript kører. Hvis hovedtråden i klikøjeblikket er optaget af at udføre en lang, synkron opgave – filtrering af et stort array, parsing af et API-svar, rendering af et komplekst komponenttræ – bliver klik-hændelsen ikke håndteret et øjeblik før, den opgave er færdig. Selve animationen kan være nok så billig. Det betyder ikke noget, hvis ingen nåede at starte den endnu.
Interaction to Next Paint (INP) er den Core Web Vitals-metrik, der måler præcis dette hul – ikke time to first byte, ikke sideindlæsningstid, men tiden mellem brugerens tryk på noget som helst og det øjeblik, hvor browseren reelt maler effekten af den interaktion på skærmen. Den erstattede den ældre metrik FID (First Input Delay) i marts 2024 af én væsentlig grund: FID målte kun den første interaktion på siden, så en side kunne få en flot score, selv hvis hvert efterfølgende klik efter det første hakkede i et sekund. INP tager alle interaktioner i hele sidens livscyklus i betragtning og rapporterer i praksis det værste, gentagne tilfælde – det kan ikke "fikses" med et godt førstehåndsindtryk.
Hvorfor JavaScript ikke kan afbrydes midtvejs
Den centrale mekanisme, uden hvilken INP ikke giver mening: JavaScript i browseren kører i en run-to-completion-model – en igangsat opgave kører fra start til slut, uden afbrydelse, uanset hvad brugeren i mellemtiden prøver at gøre. Et klik under en sådan opgave bliver ikke ignoreret – det lander i en kø og venter, til tråden bliver ledig. En opgave, der varer længere end 50 ms, klassificeres formelt som en long task (Long Tasks API) – det er ikke et vilkårligt tal, men en tilnærmelse af grænsen, hvorover brugeren begynder subjektivt at opleve, at noget "hænger", i samme ånd som Doherty-tærsklen fra artiklen om mikrointeraktioner, blot målt på den beregningsmæssige omkostning i stedet for den netværksmæssige.
INP opdeles i tre komponenter, og lange opgaver oppuster som regel den første:
- Input delay – tiden fra klikket til det øjeblik, hvor hovedtråden overhovedet begynder at håndtere hændelsen. Det er præcis her, en lang opgave blokerer alt.
- Processing time – hvor lang tid det faktisk tager at eksekvere din event-handler.
- Presentation delay – tiden fra handlerens afslutning til den reelle maling af billedet med effekten.
Yielding: sådan afgiver du tråden til browseren midt i arbejdet
Da opgaver ikke kan afbrydes udefra, er den eneste udvej at afbryde dem bevidst indefra – opdele et langt, synkront arbejde i mindre bidder og give kontrollen tilbage til browseren mellem dem, så den når at håndtere ventende input og male et billede, før du tager fat på næste stykke arbejde.
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), IKKE Promise.resolve().then()await new Promise((resolve) => setTimeout(resolve, 0));}}}return results;}
En detalje, det er let at overse, og som reelt ødelægger effekten af denne teknik: await Promise.resolve() eller queueMicrotask() afgiver ikke kontrollen til browseren. Det er mikroopgaver (microtasks) – kø af mikroopgaver tømmes helt, før browseren overhovedet overvejer at håndtere ventende input eller male et billede. Hvis du i en asynkron løkke udelukkende bruger mikroopgaver til at "opdele" arbejdet, er det fra browserens perspektiv stadig én sammenhængende opgave – blot opdelt i then()-kald i stedet for almindelige kodelinjer. For faktisk at afgive tråden har du brug for en makroopgave-grænse – setTimeout, MessageChannel (hurtigere end setTimeout(0), fordi den omgår den minimumsforsinkelse, browsere pålægger indlejrede timeouts) eller det nye, dedikerede scheduler.yield() fra Scheduler API, som desuden intelligent prioriterer fortsættelsen i forhold til andre ventende opgaver, i stedet for at havne bagerst i den almindelige kø som setTimeout.
isInputPending(): afgiv ikke tråden, hvis ingen venter på den
At afgive kontrollen efter hvert eneste element i løkken har sin pris – hver tur gennem event-loopet er en overhead. Hvis ingen har klikket på noget i det pågældende øjeblik, er det ren spild af tid at afbryde arbejdet hver 50. iteration, og det forlænger den samlede behandlingstid uden nogen gevinst for brugeren. navigator.scheduling.isInputPending() løser det med et direkte spørgsmål: venter der faktisk en uhåndteret input-hændelse i køen, før du beslutter dig for at afbryde arbejdet.
javascript
function processQueue(tasks) {while (tasks.length > 0) {if (navigator.scheduling?.isInputPending()) {break; // nogen venter faktisk – afgiv tråden nu}doExpensiveWork(tasks.shift());}if (tasks.length > 0) {setTimeout(() => processQueue(tasks), 0);}}
Det vender logikken om i forhold til det rigide "hver 50. element": i stedet for på forhånd at gætte, hvor meget arbejde der er "sikkert" mellem to yields, spørger du løbende browseren, om der overhovedet er behov. Ved en tom input-kø kan løkken fortsætte uden afbrydelser i langt mere end 50 iterationer – ved et reelt klik under behandlingen afgiver den kontrollen øjeblikkeligt, netop dér, hvor det betyder noget.
Faldgruber og god praksis
- Mikroopgaver tæller ikke som et yield. Det er den mest almindelige fejl ved forsøg på at "fikse" INP – koden ser asynkron ud, fordi den har et
await, men hvis der bag detawaitkun stårPromise.resolve(), betragter browseren stadig det hele som én uafbrydelig opgave. scheduler.yield()kræver stadig en fallback. Understøttelsen er primært begrænset til Chromium-baserede browsere – betragt det som en progressiv forbedring medsetTimeoutsom sikker reservevariant, præcis som Paint API i artiklen om CSS Houdini.- Lange opgaver fra tredjepartsscripts tæller også med i din INP-score. Analytics, reklamer, chatwidgets – de lever på den samme hovedtråd og blokerer den lige så meget som din egen kode. En
PerformanceObserver, der lytter efter poster af typenlongtask, viser dem alle, uanset hvem der har genereret dem – det er værd at måle reel produktionstrafik (RUM), ikke kun lokale tests i et tomt, cachet udviklingsmiljø. - INP måler det værste, gentagne tilfælde i hele sidens livscyklus, ikke førstehåndsindtrykket. En interaktion, der først begynder at hakke efter ti minutters brug af app'en, når der er ophobet meget data i klient-tilstanden, tæller stadig med i scoren – at teste kun de første par klik på en nyindlæst side underdriver systematisk det reelle problem.
Opsummering
Artiklen om mikrointeraktioner viste, hvordan man får selve animationen til intet at koste hovedtråden. INP viser den anden halvdel af samme ligning: selv verdens billigste animation hjælper ikke, hvis hovedtråden er optaget af en lang, uafbrydelig JavaScript-opgave i det øjeblik, brugeren klikkede. Run-to-completion-modellen gør, at den eneste vej til responsivitet er bevidst at opdele sit eget tunge arbejde i bidder og afgive kontrollen til browseren mellem dem – gennem en reel makroopgave, ikke en mikroopgave, og helst når isInputPending() faktisk bekræfter, at nogen venter på det. Hele kæden af oplevet ydelse, fra Doherty-tærsklen til INP, er kun så stærk som sit svageste led – og det led viser sig oftere og oftere ikke at være netværket eller serveren, men ens egen, for længe udelte JavaScript.