reactanimationer

Core Web Vitals: vad INP verkligen mäter, och hur scheduler.yield() räddar huvudtråden

I artikeln om mikrointeraktioner visade jag varför transform och opacity animeras smidigt även på en svag telefon – eftersom webbläsaren hanterar dem uteslutande i kompositeringssteget, på en separat GPU-tråd, utan att röra layouten. Det stämmer fortfarande, men bakom det ligger ett antagande som är värt att blotta nu: för att animationen överhuvudtaget ska starta måste webbläsaren först hantera klickhändelsen på huvudtråden – samma tråd som all din JavaScript körs på. Om huvudtråden i klickögonblicket är upptagen med att utföra en lång, synkron uppgift – filtrera en stor array, tolka ett API-svar, rendera ett komplext komponentträd – kommer klickhändelsen inte att hanteras en enda stund tidigare än när den uppgiften är klar. Själva animationen kan vara hur billig som helst. Det spelar ingen roll om ingen hann starta den ännu.

Interaction to Next Paint (INP) är det Core Web Vitals-mått som mäter exakt den luckan – inte tid till första byte, inte sidladdningstid, utan tiden mellan att användaren trycker på något och det ögonblick webbläsaren faktiskt målar upp effekten av den interaktionen på skärmen. Det ersatte det äldre måttet FID (First Input Delay) i mars 2024 av ett viktigt skäl: FID mätte bara den första interaktionen på sidan, så en sida kunde få ett utmärkt resultat även om varje efterföljande klick hackade i en sekund. INP tar hänsyn till alla interaktioner under sidans hela livscykel och rapporterar i praktiken det värsta, återkommande fallet – det går inte att "fixa" med ett bra första intryck.

Varför JavaScript inte går att avbryta mitt i

Den avgörande mekanismen, utan vilken INP inte har någon mening: JavaScript i webbläsaren körs enligt modellen run-to-completion – en påbörjad uppgift körs från början till slut, utan avbrott, oavsett vad användaren under tiden försöker göra. Ett klick under en sådan uppgift ignoreras inte – det hamnar i en kö och väntar tills tråden blir ledig. En uppgift som tar längre tid än 50 ms klassificeras formellt som en long task (Long Tasks API) – det är inget godtyckligt tal, utan en approximation av gränsen där användaren subjektivt börjar uppleva att något "hänger", i samma anda som Dohertys tröskel från artikeln om mikrointeraktioner, fast mätt utifrån beräkningskostnad snarare än nätverkskostnad.

INP delas upp i tre komponenter, och långa uppgifter blåser oftast upp den första:

  • Input delay (fördröjning vid indata) – tiden från klicket till det ögonblick huvudtråden överhuvudtaget börjar hantera händelsen. Det är exakt här en lång uppgift blockerar allt.
  • Processing time (bearbetningstid) – hur lång tid själva utförandet av din event-handler faktiskt tar.
  • Presentation delay (fördröjning vid presentation) – tiden från att handlern är klar till att bilden med effekten faktiskt målas upp.

Yielding: hur du lämnar tillbaka tråden till webbläsaren mitt i arbetet

Eftersom en uppgift inte går att avbryta utifrån är den enda utvägen att medvetet avbryta den inifrån – dela upp det långa, synkrona arbetet i mindre bitar och lämna tillbaka kontrollen till webbläsaren mellan dem, så att den hinner hantera väntande indata och måla upp en bildruta innan du tar dig an nästa bit arbete.

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

En detalj som lätt förbises och som på riktigt förstör effekten av den här tekniken: await Promise.resolve() eller queueMicrotask() lämnar inte tillbaka kontrollen till webbläsaren. Det är mikrouppgifter (microtasks) – mikrouppgiftskön töms helt innan webbläsaren ens överväger att hantera väntande indata eller måla upp en bildruta. Om du i en asynkron loop enbart använder mikrouppgifter för att "dela upp" arbetet är det ur webbläsarens perspektiv ändå en enda, sammanhängande uppgift – bara uppdelad i then() i stället för raka kodrader. För att verkligen lämna tillbaka tråden behöver du en gräns i form av en makrouppgift – setTimeout, MessageChannel (snabbare än setTimeout(0), eftersom den kringgår den minimifördröjning webbläsare tvingar fram vid nästlade timeouts) eller det nya, dedikerade scheduler.yield() från Scheduler API, som dessutom smart prioriterar fortsättningen i förhållande till andra väntande uppgifter, i stället för att hamna sist i en vanlig kö som setTimeout.

isInputPending(): lämna inte tillbaka tråden om ingen väntar på den

Att lämna tillbaka kontrollen efter varje enskilt element i loopen har sin kostnad – varje varv genom händelseloopen innebär overhead. Om ingen har klickat på något just då är det ren tidsförlust att avbryta arbetet var 50:e iteration, vilket förlänger den totala bearbetningstiden utan någon nytta för användaren. navigator.scheduling.isInputPending() löser det genom att ställa frågan rakt ut: väntar det faktiskt en ohanterad indata-händelse i kön, innan du bestämmer dig för att avbryta arbetet.

javascript

function processQueue(tasks) {
while (tasks.length > 0) {
if (navigator.scheduling?.isInputPending()) {
break; // någon väntar faktiskt – lämna tillbaka tråden nu
}
doExpensiveWork(tasks.shift());
}
if (tasks.length > 0) {
setTimeout(() => processQueue(tasks), 0);
}
}

Det vänder på logiken jämfört med ett fast "var 50:e element": i stället för att gissa i förväg hur mycket arbete som är "säkert" mellan en yield och nästa, frågar du webbläsaren löpande om det just nu finns ett sådant behov. Med en tom indatakö kan loopen fortsätta utan avbrott betydligt längre än 50 iterationer – vid ett verkligt klick under bearbetningen lämnar den tillbaka kontrollen omedelbart, precis där det spelar roll.

Fallgropar och god praxis

  • Mikrouppgifter räknas inte som en yield. Det är det vanligaste misstaget när man försöker "fixa" INP – koden ser asynkron ut eftersom den har await, men om det bara ligger en Promise.resolve() bakom det await, behandlar webbläsaren ändå hela klumpen som en enda, oavbrytbar uppgift.
  • scheduler.yield() behöver fortfarande en fallback. Stödet är i praktiken begränsat till Chromium-baserade webbläsare – behandla det som en progressiv förbättring med setTimeout som en säker reservlösning, precis som Paint API i artikeln om CSS Houdini.
  • Långa uppgifter från tredjepartsskript räknas också in i ditt INP-resultat. Analytics, annonser, chattwidgetar – de lever på samma huvudtråd och blockerar den precis lika mycket som din egen kod. En PerformanceObserver som lyssnar på poster av typen longtask visar alla, oavsett vem som orsakade dem – det är värt att mäta verklig produktionstrafik (RUM), inte bara lokala tester i en tom, cachead utvecklingsmiljö.
  • INP mäter det värsta, återkommande fallet under sidans hela livscykel, inte första intrycket. En interaktion som hackar först efter tio minuters användning av appen, när klientens state har hunnit samla på sig mycket data, räknas fortfarande in i resultatet – att bara testa de första klicken på en nyss laddad sida underskattar systematiskt det verkliga problemet.

Sammanfattning

Artikeln om mikrointeraktioner visade hur man ser till att själva animationen inte kostar huvudtråden något. INP visar den andra halvan av samma ekvation: inte ens världens billigaste animation hjälper om huvudtråden är upptagen med en lång, oavbrytbar JavaScript-uppgift i det ögonblick användaren klickade. Run-to-completion-modellen gör att det enda sättet att uppnå respons är att medvetet dela upp sitt eget, tunga arbete i bitar och lämna tillbaka kontrollen till webbläsaren mellan dem – via en riktig makrouppgift, inte en mikrouppgift, och helst när isInputPending() faktiskt bekräftar att någon väntar på det. Hela kedjan av upplevd prestanda, från Dohertys tröskel till INP, är bara så stark som sin svagaste länk – och den länken visar sig allt oftare vara varken nätverket eller servern, utan den egna, alltför länge odelade JavaScript-koden.