INP powyżej 200 ms po wygenerowaniu strony: który z trzech etapów interakcji naprawiać
INP zastąpił FID w marcu 2024. Jak zmierzyć trzy etapy interakcji na wygenerowanej stronie i który z nich naprawiać, zanim oddasz projekt klientowi.
Użytkownik klika „Dodaj do koszyka”, a licznik w nagłówku zmienia się dopiero po pół sekundy. Nic się nie zawiesiło, nic nie wyrzuciło błędu w konsoli — strona po prostu odpowiada z opóźnieniem, które widać gołym okiem. Ten objaw ma od marca 2024 własną metrykę w Core Web Vitals: Interaction to Next Paint zastąpił wtedy First Input Delay.
Zamiana nie była kosmetyczna. FID mierzył wyłącznie opóźnienie pierwszej interakcji na stronie i tylko do momentu, w którym handler ruszył — to, co działo się dalej, nie liczyło się wcale. INP obserwuje wszystkie kliknięcia, dotknięcia ekranu i naciśnięcia klawisza przez całą wizytę i mierzy pełną drogę: od zdarzenia do klatki, na której użytkownik faktycznie widzi efekt. Próg dobrego wyniku to 200 ms. Przedział od 200 do 500 ms oznacza „wymaga poprawy”, powyżej 500 ms — złą responsywność. Wynik pochodzi z 75. percentyla odsłon w polu, a przy dużej liczbie interakcji metryka pomija jedną najgorszą na każde 50, żeby pojedynczy zryw przeglądarki nie decydował o ocenie strony.
Dobry LCP i zły INP to nie jest sprzeczność
Strona wygenerowana przez model często wypada przyzwoicie w metrykach ładowania i słabo w responsywności, bo to są pomiary z dwóch różnych momentów życia strony. LCP zamyka się w chwili wyrenderowania największego elementu i da się go poprawić rzeczami statycznymi: rozmiarem hero, formatem obrazu, kolejnością skryptów w head. INP zaczyna się dokładnie tam, gdzie LCP się kończy — mierzy to, co robi JavaScript w reakcji na człowieka.
Kod z modelu ma kilka nawyków, które uderzają wprost w ten drugi pomiar. Wszystko bywa komponentem klienckim, bo tak jest bezpieczniej dla generatora. Handler zdarzenia dostaje całą logikę w jednym ciągu, bo prompt brzmiał „obsłuż kliknięcie”, a nie „rozłóż pracę na klatki”. Filtrowanie listy liczy się synchronicznie na każdym wciśniętym klawiszu, bo debounce nie był częścią zamówienia. Żadna z tych rzeczy nie jest błędem, który wywali test albo pokaże się w przeglądzie diffa. Wszystkie zobaczy użytkownik na telefonie za 900 zł.
Jedna interakcja to trzy etapy, a naprawia się zwykle jeden
INP nie jest liczbą, którą się „poprawia”. To suma trzech odcinków czasu i dopóki nie wiadomo, który z nich zjada budżet, każda optymalizacja jest zgadywaniem. Rozkład wygląda tak:
| Etap | Co się w nim dzieje | Typowa przyczyna w kodzie z modelu |
|---|---|---|
| Input delay | Od zdarzenia do startu pierwszego handlera. Wątek główny jest zajęty czymś innym. | Hydracja całej strony, skrypty analityczne i widgety wykonujące się w tle po załadowaniu. |
| Processing duration | Wykonanie wszystkich nasłuchiwaczy podpiętych pod to zdarzenie. | Handler robiący komplet pracy synchronicznie: filtrowanie, sortowanie, zapis do storage, wysyłka zdarzenia. |
| Presentation delay | Od końca handlerów do wyrenderowania następnej klatki. | Wymuszony reflow, ogromny DOM listy bez wirtualizacji, ciężki callback w requestAnimationFrame. |
Ta sama liczba 350 ms znaczy co innego w każdym z trzech wierszy i prowadzi do innej poprawki. Dlatego pierwszy krok to nie profiler, tylko atrybucja.
Pomiar w polu: web-vitals z atrybucją
Wartość INP z laboratorium zależy od tego, co akurat kliknęliście podczas testu. W polu zależy od tego, co klikają ludzie — i tylko ta druga liczba trafia do raportu, który klient zobaczy. Biblioteka web-vitals ma osobny build z atrybucją, który zwraca rozbicie na etapy razem z selektorem elementu:
import {onINP} from 'web-vitals/attribution';
onINP(({value, rating, attribution}) => {
const {
interactionTarget, // np. 'button#dodaj-do-koszyka'
interactionType, // 'pointer' albo 'keyboard'
inputDelay,
processingDuration,
presentationDelay,
longAnimationFrameEntries
} = attribution;
navigator.sendBeacon('/rum', JSON.stringify({
value, rating, interactionTarget, interactionType,
inputDelay, processingDuration, presentationDelay
}));
});
Trzy liczby z tego beacona wystarczą, żeby wiedzieć, gdzie kopać. Wysoki input delay wskazuje na pracę wykonywaną obok interakcji — zwykle na to, co dzieje się tuż po załadowaniu. Wysoki processing duration to wina konkretnego handlera i tu atrybucja poda nawet nazwę funkcji. Wysoki presentation delay oznacza kosztowne przeliczenie stylów i układu albo zbyt duży DOM.
Pomiar u siebie: long-animation-frame w konsoli
Do pracy na własnej maszynie jest API długich klatek animacji. Ramka jest „długa”, gdy aktualizacja renderowania opóźnia się o ponad 50 ms. Interfejs wystawia między innymi duration, blockingDuration oraz tablicę scripts z informacją, co dokładnie się w tej klatce wykonywało — łącznie z adresem pliku, nazwą funkcji i pozycją w źródle. To ostatnie jest różnicą między „coś muli” a „muli updateCart w linii 214”.
new PerformanceObserver((list) => {
for (const frame of list.getEntries()) {
if (frame.blockingDuration === 0) continue;
const najdluzszy = frame.scripts
.slice()
.sort((a, b) => b.duration - a.duration)[0];
console.log(
Math.round(frame.duration) + ' ms klatki, ' +
Math.round(frame.blockingDuration) + ' ms blokady →',
najdluzszy?.invoker, // 'BUTTON#dodaj.onclick'
najdluzszy?.sourceFunctionName, // 'updateCart'
najdluzszy?.sourceURL
);
}
}).observe({type: 'long-animation-frame', buffered: true});
Jedno zastrzeżenie, o którym trzeba wiedzieć przed obiecaniem klientowi raportu: API działa w Chrome i Edge od wersji 123, a Firefox i Safari go nie mają. Dane z tego mechanizmu opisują więc jedną rodzinę przeglądarek. Jeśli ruch na stronie jest w większości z iPhone’ów, atrybucja pokaże wam mniejszość sesji i trzeba to powiedzieć wprost, zamiast przedstawiać wykres jako obraz całości.
Handler, który robi wszystko w jednym ciągu
Najczęstszy wzorzec do naprawienia wygląda tak, jak poniżej — i przechodzi każdy przegląd kodu, bo jest poprawny. Kliknięcie uruchamia pięć rzeczy, z których użytkownik czeka tylko na pierwszą:
przycisk.addEventListener('click', () => {
dodajDoKoszyka(produkt); // to widzi użytkownik
przeliczRabaty(koszyk); // to może poczekać
zapiszDoLocalStorage(koszyk); // to może poczekać
wyslijZdarzenieAnalityczne(); // to może poczekać
odswiezRekomendacje(); // to może poczekać dłużej
});
Poprawka polega na oddaniu wątku głównego po tym, co jest potrzebne do najbliższej klatki. Do tego służy scheduler.yield(), które zwraca obietnicę i wznawia pracę z priorytetem wyższym niż zwykłe zadanie z setTimeout. Metoda jest w Chrome od wersji 129 i nie ma jej w Firefoksie ani Safari, więc idzie z detekcją i zapasowym wariantem:
const oddajWatek = () =>
globalThis.scheduler?.yield
? scheduler.yield()
: new Promise((r) => setTimeout(r, 0));
przycisk.addEventListener('click', async () => {
dodajDoKoszyka(produkt); // klatka może się wyrenderować tutaj
await oddajWatek();
przeliczRabaty(koszyk);
zapiszDoLocalStorage(koszyk);
await oddajWatek();
wyslijZdarzenieAnalityczne();
odswiezRekomendacje();
});
Ta zmiana nie skraca łącznej pracy ani o milisekundę — przesuwa tylko moment renderowania przed resztę zadań. Właśnie dlatego działa na INP i nic nie daje w metrykach, które sumują czas wykonania skryptów. Wynik sprawdzajcie na tym samym elemencie przed poprawką i po niej, przy wymuszonym spowolnieniu procesora w narzędziach deweloperskich, bo na maszynie do pracy różnica potrafi zniknąć w szumie.
Odczyt po zapisie, czyli skąd bierze się presentation delay
Drugi wzorzec dotyczy ostatniego etapu i bywa trudniejszy do wypatrzenia, bo rozkłada się na dwie linijki wyglądające niewinnie:
// wymuszony synchroniczny układ w pętli
for (const karta of karty) {
karta.classList.add('rozwinieta'); // zapis
karta.style.height = karta.scrollHeight + 'px'; // odczyt → reflow
}
Odczyt właściwości geometrycznej tuż po zmianie stylu zmusza przeglądarkę do policzenia układu od razu, zamiast zrobienia tego raz przed renderowaniem. W pętli po kilkudziesięciu elementach kosztuje to tyle razy, ile jest iteracji. Rozwiązanie jest mechaniczne: najpierw wszystkie odczyty, potem wszystkie zapisy. Ten sam efekt daje zresztą duży DOM sam z siebie — im więcej węzłów, tym droższa każda aktualizacja renderowania, niezależnie od jakości handlerów.
Jeżeli wasza strona wyświetla listy, które model wygenerował jako zwykłe mapowanie po tablicy, sprawdźcie liczbę węzłów przy realnych danych klienta, a nie przy trzech pozycjach z makiety. To jest ta sama klasa problemu co przekazanie projektu bez opisanych założeń: kod działa u wykonawcy i przestaje działać u odbiorcy, bo warunki brzegowe nigdy nie zostały spisane — reguły spisywania procesu tak, żeby wykonał go ktoś inny, stosują się do dokumentacji technicznej tak samo jak do procedur w firmie.
Lista kontrolna przed oddaniem strony
- Zmierzcie INP na trzech najczęstszych interakcjach, nie na losowym klikaniu: dodanie do koszyka, filtr listy, otwarcie menu mobilnego.
- Zapiszcie rozbicie na input delay, processing duration i presentation delay. Bez tego nie wiadomo, co poprawiać.
- Powtórzcie pomiar przy spowolnieniu procesora — na sprzęcie deweloperskim większość problemów jest niewidoczna.
- Sprawdźcie, ile skryptów innych firm wykonuje się po załadowaniu; one obciążają input delay wszystkich interakcji naraz.
- Przejrzyjcie handlery pod kątem pracy, na którą użytkownik nie czeka, i oddajcie wątek po części widocznej.
- Poszukajcie odczytów geometrii wewnątrz pętli modyfikujących style.
- Policzcie węzły DOM na najdłuższej realnej liście w serwisie.
- Wepnijcie zbieranie metryki z pola przed oddaniem projektu, żeby po miesiącu było o czym rozmawiać z klientem.
Co z tego wynika dla wyceny
Responsywność interfejsu nie jest pozycją, którą da się dopisać na końcu projektu za dwie godziny. Pomiar wymaga wpięcia zbierania danych z pola i odczekania, aż nazbiera się ruch; poprawki dotykają struktury komponentów, a nie ustawień. Jeśli w ofercie jest zdanie o zgodności z Core Web Vitals, to zdanie ma dziś w środku INP i pracę, której model za was nie wykona — on ten problem produkuje, nie rozwiązuje.
Praktyczna decyzja do podjęcia przed następną ofertą jest jedna: albo wpisujecie pomiar responsywności w zakres jako osobną, wycenioną pozycję z konkretnymi interakcjami do sprawdzenia, albo świadomie zostawiacie go poza umową i mówicie klientowi, że strona nie ma na to gwarancji. Trzecia droga — obiecanie dobrych Core Web Vitals i sprawdzenie tylko LCP — kończy się poprawkami po odbiorze, na wasz koszt.