Core Web Vitals: hva INP faktisk måler, og hvordan scheduler.yield() redder hovedtråden
I artikkelen om mikrointeraksjoner viste jeg hvorfor transform og opacity animeres jevnt selv på en svak telefon – fordi nettleseren håndterer dem utelukkende i kompositeringstrinnet, på en egen GPU-tråd, uten å røre layouten. Det stemmer fortsatt, men bak det ligger en forutsetning det nå er verdt å avdekke: for at animasjonen i det hele tatt skal starte, må nettleseren først håndtere klikk-hendelsen på hovedtråden – den samme tråden all JavaScript-en din kjører på. Hvis hovedtråden i det øyeblikket du klikker er opptatt med å utføre en lang, synkron oppgave – filtrering av en stor array, parsing av et API-svar, rendering av et komplekst komponenttre – blir klikk-hendelsen ikke håndtert et øyeblikk før den oppgaven er ferdig. Selve animasjonen kan være så billig den bare vil. Det spiller ingen rolle hvis ingen har rukket å starte den ennå.
Interaction to Next Paint (INP) er Core Web Vitals-metrikken som måler nøyaktig dette gapet – ikke tiden til første byte, ikke sidens lastetid, bare tiden mellom brukerens trykk på hva som helst og øyeblikket nettleseren faktisk maler effekten av den interaksjonen på skjermen. Den erstattet den eldre metrikken FID (First Input Delay) i mars 2024 av én viktig grunn: FID målte kun den første interaksjonen på siden, så en side kunne få en flott score selv om hvert eneste klikk etter det første hakket i et helt sekund. INP tar hensyn til alle interaksjoner gjennom sidens hele livssyklus og rapporterer i praksis det verste, gjentakende tilfellet – det lar seg ikke «fikse» med et godt førsteinntrykk.
Hvorfor JavaScript ikke kan avbrytes midtveis
Den avgjørende mekanismen INP ikke gir mening uten: JavaScript i nettleseren følger run-to-completion-modellen – en oppgave som først er startet, kjører fra start til slutt, uten avbrudd, uansett hva brukeren i mellomtiden prøver å gjøre. Et klikk mens en slik oppgave pågår blir ikke ignorert – det havner i en kø og venter til tråden blir ledig. En oppgave som varer mer enn 50 ms klassifiseres formelt som en long task (Long Tasks API) – ikke et vilkårlig tall, men en tilnærming til grensen der brukeren subjektivt begynner å oppleve at noe «henger», i samme ånd som Doherty-terskelen fra artikkelen om mikrointeraksjoner, bare målt på beregningskostnadssiden, ikke nettverkssiden.
INP deles opp i tre komponenter, og lange oppgaver blåser som regel opp den første av dem:
- Inputforsinkelse (input delay) – tiden fra klikket til hovedtråden i det hele tatt begynner å håndtere hendelsen. Det er nettopp her en lang oppgave blokkerer alt.
- Prosesseringstid (processing time) – hvor lang tid selve utførelsen av hendelseshandleren din faktisk tar.
- Presentasjonsforsinkelse (presentation delay) – tiden fra handleren er ferdig til rammen med effekten faktisk males.
Yielding: hvordan gi tråden tilbake til nettleseren midt i arbeidet
Siden en oppgave ikke kan avbrytes utenfra, er den eneste utveien å avbryte den bevisst innenfra – dele det lange, synkrone arbeidet opp i mindre biter og gi kontrollen tilbake til nettleseren mellom dem, slik at den rekker å håndtere ventende input og male en ramme, før du tar fatt på neste bit arbeid.
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 detalj som er lett å overse, og som reelt sett ødelegger effekten av denne teknikken: await Promise.resolve() eller queueMicrotask() gir ikke kontrollen tilbake til nettleseren. Dette er mikrooppgaver (microtasks) – mikrooppgavekøen tømmes fullstendig før nettleseren i det hele tatt vurderer å håndtere ventende input eller male en ramme. Hvis du i en asynkron løkke utelukkende bruker mikrooppgaver for å «dele opp» arbeidet, er det fra nettleserens perspektiv likevel én sammenhengende oppgave – bare oppdelt i then()-kall i stedet for enkle kodelinjer. For faktisk å gi fra deg tråden trenger du en makrooppgave-grense – setTimeout, MessageChannel (raskere enn setTimeout(0), fordi den unngår den minimumsforsinkelsen nettlesere pålegger nøstede timeouts) eller den nye, dedikerte scheduler.yield() fra Scheduler API, som i tillegg intelligent prioriterer fortsettelsen i forhold til andre ventende oppgaver, i stedet for å havne bakerst i den vanlige køen slik setTimeout gjør.
isInputPending(): ikke gi fra deg tråden hvis ingen venter på den
Å gi fra seg kontrollen etter hvert eneste element i løkken har sin kostnad – hver runde gjennom hendelsesløkken innebærer overhead. Hvis ingen har klikket på noe akkurat da, er det ren sløsing med tid å avbryte arbeidet hver 50. iterasjon, noe som forlenger den totale prosesseringstiden uten noen fordel for brukeren. navigator.scheduling.isInputPending() løser dette ved å stille spørsmålet direkte: venter det faktisk en uhåndtert input-hendelse i køen, før du bestemmer deg for å avbryte arbeidet.
javascript
function processQueue(tasks) {while (tasks.length > 0) {if (navigator.scheduling?.isInputPending()) {break; // noen venter faktisk – gi fra deg tråden nå}doExpensiveWork(tasks.shift());}if (tasks.length > 0) {setTimeout(() => processQueue(tasks), 0);}}
Dette snur logikken opp ned i forhold til den faste regelen «hver 50. element»: i stedet for å gjette på forhånd hvor mye arbeid som er «trygt» mellom én yield og den neste, spør du nettleseren fortløpende om det faktisk er behov for det. Med en tom input-kø kan løkken fortsette uten avbrudd i langt mer enn 50 iterasjoner – ved et reelt klikk under prosesseringen gir den fra seg kontrollen umiddelbart, akkurat der det betyr noe.
Fallgruver og gode praksiser
- Mikrooppgaver teller ikke som en yield. Dette er den vanligste feilen når man prøver å «fikse» INP – koden ser asynkron ut fordi den har
await, men hvis det bak denawait-en bare står enPromise.resolve(), behandler nettleseren fortsatt hele greia som én uavbrytelig oppgave. scheduler.yield()krever fortsatt en fallback. Støtten er hovedsakelig begrenset til Chromium-baserte nettlesere – behandle den som progressiv forbedring medsetTimeoutsom en trygg reservevariant, akkurat som Paint API i artikkelen om CSS Houdini.- Lange oppgaver fra tredjepartsskript teller også med i INP-scoren din. Analytics, annonser, chat-widgets – de lever på den samme hovedtråden og blokkerer den akkurat som din egen kode. En
PerformanceObserversom lytter på oppføringer av typenlongtaskviser alle sammen, uansett hvem som genererte dem – det er verdt å måle reell produksjonstrafikk (RUM), ikke bare lokale tester i et tomt, cachet utviklermiljø. - INP måler det verste, gjentakende tilfellet gjennom sidens hele livssyklus, ikke førsteinntrykket. En interaksjon som først hakker etter ti minutters bruk av applikasjonen, når klienttilstanden har samlet opp mye data, teller fortsatt med i scoren – å bare teste de første par klikkene på en nylastet side undervurderer systematisk det reelle problemet.
Oppsummering
Artikkelen om mikrointeraksjoner viste hvordan man kan få selve animasjonen til ikke å koste hovedtråden noe. INP viser den andre halvparten av den samme ligningen: selv verdens billigste animasjon hjelper ikke hvis hovedtråden er opptatt med en lang, uavbrytelig JavaScript-oppgave i det øyeblikket brukeren klikket. Run-to-completion-modellen gjør at den eneste veien til respons er å bevisst dele opp sitt eget tunge arbeid i biter og gi kontrollen tilbake til nettleseren mellom dem – gjennom en ekte makrooppgave, ikke en mikrooppgave, og helst når isInputPending() faktisk bekrefter at noen venter på det. Hele kjeden av opplevd ytelse, fra Doherty-terskelen til INP, er bare så sterk som sitt svakeste ledd – og det leddet viser seg stadig oftere å ikke være nettverket eller serveren, men din egen, for lenge udelte JavaScript.