reactanimacje

Core Web Vitals: co INP naprawdę mierzy, i jak scheduler.yield() ratuje główny wątek

Core Web Vitals: co INP naprawdę mierzy, i jak scheduler.yield() ratuje główny wątek

W artykule o mikrointerakcjach pokazywałem, dlaczego transform i opacity animują się płynnie nawet na słabym telefonie – bo przeglądarka obsługuje je wyłącznie na etapie kompozycji, na osobnym wątku GPU, bez dotykania layoutu. To wciąż prawda, ale kryje się za nią założenie, które warto teraz obnażyć: żeby ta animacja w ogóle wystartowała, przeglądarka musi najpierw obsłużyć zdarzenie kliknięcia na głównym wątku – tym samym wątku, na którym wykonuje się cały Twój JavaScript. Jeśli w momencie kliknięcia główny wątek jest zajęty wykonywaniem długiego, synchronicznego zadania – filtrowaniem dużej tablicy, parsowaniem odpowiedzi API, renderem złożonego drzewa komponentów – zdarzenie kliknięcia nie zostanie obsłużone ani chwili wcześniej, niż to zadanie się skończy. Sama animacja może być tania jak nic. Nie ma to znaczenia, jeśli nikt nie zdążył jej jeszcze uruchomić.

Interaction to Next Paint (INP) to metryka Core Web Vitals, która mierzy dokładnie tę lukę – nie czas do pierwszego bajtu, nie czas ładowania strony, tylko czas między naciśnięciem przez użytkownika czegokolwiek a momentem, w którym przeglądarka realnie wymalowuje na ekranie efekt tej interakcji. Zastąpiła starszą metrykę FID (First Input Delay) w marcu 2024 roku z jednego istotnego powodu: FID mierzyło tylko pierwszą interakcję na stronie, więc strona mogła mieć świetny wynik, nawet jeśli każde kolejne kliknięcie po pierwszym zacinało się na sekundę. INP bierze pod uwagę wszystkie interakcje w całym cyklu życia strony i raportuje w praktyce najgorszy, powtarzalny przypadek – nie da się go „naprawić” dobrym pierwszym wrażeniem.

Dlaczego JavaScript nie da się przerwać w połowie

Kluczowy mechanizm, bez którego INP nie ma sensu: JavaScript w przeglądarce działa w modelu run-to-completion – raz uruchomione zadanie wykonuje się od początku do końca, bez przerwania, niezależnie od tego, co w międzyczasie próbuje zrobić użytkownik. Kliknięcie w trakcie trwania takiego zadania nie jest ignorowane – trafia do kolejki i czeka, aż wątek się zwolni. Zadanie trwające dłużej niż 50 ms jest formalnie klasyfikowane jako long task (Long Tasks API) – to nie arbitralna liczba, tylko przybliżenie granicy, powyżej której użytkownik zaczyna subiektywnie odczuwać, że coś "wisi", zbliżone w duchu do progu Doherty'ego z artykułu o mikrointerakcjach, tylko mierzone po stronie kosztu obliczeniowego, nie sieciowego.

INP rozkłada się na trzy składowe, i długie zadania najczęściej rozdymają pierwszą z nich:

  • Opóźnienie wejściowe (input delay) – czas od kliknięcia do momentu, w którym główny wątek w ogóle zaczyna obsługiwać zdarzenie. To właśnie tutaj długie zadanie blokuje wszystko.
  • Czas przetwarzania (processing time) – ile faktycznie trwa wykonanie Twojego handlera zdarzenia.
  • Opóźnienie prezentacji (presentation delay) – czas od zakończenia handlera do realnego wymalowania klatki z efektem.

Yielding: jak oddać wątek przeglądarce w połowie pracy

Skoro zadania nie da się przerwać z zewnątrz, jedynym wyjściem jest przerywanie ich świadomie od środka – podzielenie długiej, synchronicznej pracy na mniejsze kawałki i oddawanie kontroli przeglądarce między nimi, żeby zdążyła obsłużyć zaległe input i wymalować klatkę, zanim podejmiesz kolejny kawałek pracy.

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

Szczegół, który łatwo przeoczyć i który realnie psuje efekt tej techniki: await Promise.resolve() albo queueMicrotask() nie oddaje kontroli przeglądarce. To mikrozadania (microtasks) – kolejka mikrozadań jest opróżniana w całości, zanim przeglądarka w ogóle rozważy obsłużenie zaległego inputu czy wymalowanie klatki. Jeśli w pętli asynchronicznej używasz wyłącznie mikrozadań do „dzielenia” pracy, z perspektywy przeglądarki to i tak jedno, ciągłe zadanie – tyle że rozbite na then() zamiast na proste linijki kodu. Żeby faktycznie oddać wątek, potrzebujesz granicy makrozadaniasetTimeout, MessageChannel (szybszy niż setTimeout(0), bo omija minimalne opóźnienie narzucane przez przeglądarki na zagnieżdżone timeouty) albo nowego, dedykowanego do tego scheduler.yield() z Scheduler API, który dodatkowo inteligentnie priorytetyzuje kontynuację względem innych oczekujących zadań, zamiast trafiać na koniec zwykłej kolejki jak setTimeout.

isInputPending(): nie oddawaj wątku, jeśli nikt na niego nie czeka

Oddawanie kontroli po każdym pojedynczym elemencie pętli ma swój koszt – każde przejście przez pętlę zdarzeń to narzut. Jeśli w danym momencie nikt nic nie kliknął, przerywanie pracy co 50 iteracji jest czystą stratą czasu, wydłużającą całkowity czas przetwarzania bez żadnej korzyści dla użytkownika. navigator.scheduling.isInputPending() rozwiązuje to pytaniem wprost: czy w kolejce faktycznie czeka jakieś nieobsłużone zdarzenie wejściowe, zanim zdecydujesz się przerwać pracę.

javascript

function processQueue(tasks) {
while (tasks.length > 0) {
if (navigator.scheduling?.isInputPending()) {
break; // ktoś faktycznie czeka – oddaj wątek teraz
}
doExpensiveWork(tasks.shift());
}
if (tasks.length > 0) {
setTimeout(() => processQueue(tasks), 0);
}
}

To odwraca logikę względem sztywnego „co 50 elementów”: zamiast zgadywać z góry, ile pracy jest „bezpieczne” między jednym yieldem a drugim, pytasz przeglądarkę na bieżąco, czy akurat jest taka potrzeba. Przy pustej kolejce inputu pętla może kontynuować bez przerw znacznie dłużej niż 50 iteracji – przy realnym kliknięciu w trakcie przetwarzania odda kontrolę natychmiast, dokładnie tam, gdzie to ma znaczenie.

Pułapki i dobre praktyki

  • Mikrozadania nie liczą się jako yield. To najczęstszy błąd przy próbie „naprawienia” INP – kod wygląda asynchronicznie, bo ma await, ale jeśli za tym await stoi tylko Promise.resolve(), przeglądarka wciąż traktuje całość jako jedno nieprzerywalne zadanie.
  • scheduler.yield() wciąż wymaga fallbacku. Wsparcie ograniczone jest głównie do przeglądarek opartych na Chromium – traktuj go jako progresywne wzbogacenie z setTimeout jako bezpiecznym wariantem zapasowym, dokładnie tak, jak Paint API z artykułu o CSS Houdini.
  • Długie zadania ze skryptów trzecich też liczą się do Twojego wyniku INP. Analytics, reklamy, widgety czatu – żyją na tym samym głównym wątku i blokują go tak samo jak Twój własny kod. PerformanceObserver nasłuchujący na wpisy typu longtask pokaże je wszystkie, niezależnie od tego, kto je wygenerował – warto mierzyć realny ruch produkcyjny (RUM), nie tylko lokalne testy na pustym, cache'owanym środowisku deweloperskim.
  • INP mierzy najgorszy powtarzalny przypadek w całym cyklu życia strony, nie pierwsze wrażenie. Interakcja, która zacina się dopiero po dziesięciu minutach korzystania z aplikacji, gdy w stanie klienckim narosło dużo danych, wciąż liczy się do wyniku – testowanie tylko pierwszych kilku kliknięć na świeżo załadowanej stronie systematycznie zaniża realny problem.

Podsumowanie

Artykuł o mikrointerakcjach pokazywał, jak sprawić, żeby sama animacja nic nie kosztowała głównego wątku. INP pokazuje drugą połowę tego samego równania: nawet najtańsza animacja na świecie nie pomoże, jeśli główny wątek jest zajęty długim, nieprzerywalnym zadaniem JavaScriptu w momencie, w którym użytkownik kliknął. Model run-to-completion sprawia, że jedynym sposobem na responsywność jest świadome dzielenie własnej, ciężkiej pracy na kawałki i oddawanie kontroli przeglądarce między nimi – przez prawdziwe makrozadanie, nie mikrozadanie, i najlepiej wtedy, gdy isInputPending() faktycznie potwierdzi, że ktoś na to czeka. Cały łańcuch postrzeganej wydajności, od progu Doherty'ego po INP, jest tak silny, jak jego najsłabsze ogniwo – a tym ogniwem coraz częściej okazuje się nie sieć ani serwer, tylko własny, zbyt długo niepodzielony JavaScript.