Najpierw INP, bo to ta metryka wywraca wynik sklepu na WooCommerce, a nie obrazek w nagłówku.
Klient z branży wyposażenia wnętrz przyszedł z klasycznym zestawem objawów: Lighthouse pokazywał 42 na mobile, ale realna skarga brzmiała inaczej. „Klikam dodaj do koszyka i przez sekundę nic się nie dzieje, więc klikam drugi raz”. To zdanie jest diagnozą samą w sobie. Lighthouse nie mierzy INP, bo Lighthouse nie klika. Metryka, która opisywała ten problem, żyła wyłącznie w danych polowych: 450 ms INP na 75. percentylu w CrUX, przy progu dobrego wyniku 200 ms.
Poniżej dokładny przebieg pracy: co blokowało główny wątek, jak to znaleźliśmy, co zostało zmienione i gdzie każda z tych zmian ma swoją cenę. Bez ukrytych kroków, bo one są najczęściej tym, co sprawia, że cudza recepta nie działa u Ciebie.
Fragmenty koszyka są głównym podejrzanym, zanim spojrzysz na cokolwiek innego
WooCommerce od lat dowozi mechanizm zwany Cart Fragments. Po każdym dodaniu produktu, a w domyślnej konfiguracji także przy zwykłym wejściu na stronę, przeglądarka strzela do ?wc-ajax=get_refreshed_fragments. Odpowiedź to kawałki HTML, którymi skrypt podmienia licznik koszyka w nagłówku.
Trzy rzeczy w tym mechanizmie tłuką INP jednocześnie.
Po pierwsze, to żądanie omija cache HTTP z definicji. Musi trafić do PHP, bo licznik koszyka jest per sesja. Na stronie, która poza tym jest w całości serwowana z cache krawędziowego, to jedyne żądanie ustawia realne opóźnienie odpowiedzi, a przy sesjach WooCommerce trzymanych w bazie potrafi dołożyć kilkaset milisekund zanim cokolwiek wróci.
Po drugie, odpowiedź jest podmieniana przez jQuery.replaceWith() na fragmencie nagłówka. Każda taka podmiana to usunięcie i ponowne wstawienie węzłów DOM, czyli wymuszony layout i paint na elemencie, który jest widoczny na każdej podstronie. Jeśli w nagłówku siedzi lepki pasek z position: sticky i cieniem, przeglądarka przelicza znacznie więcej, niż sugeruje rozmiar samego licznika.
Po trzecie, i to jest część, którą ludzie pomijają: przycisk „dodaj do koszyka” w wariancie AJAX ma handler, który wykonuje pracę synchronicznie w tym samym zadaniu, w którym obsługuje kliknięcie. INP mierzy czas od interakcji do następnej klatki, więc wszystko, co handler zrobi zanim odda sterowanie, ląduje w wyniku.
Co zostało zrobione:
- Wyłączenie fragmentów na podstronach, które nie mają koszyka w widoku. Nie globalne
wp_dequeue_script('wc-cart-fragments'), bo to zabija licznik wszędzie, tylko warunek na typie widoku: strony wpisów, strona kontaktu i archiwa kategorii treści nie ładują skryptu wcale. - Licznik koszyka wyciągnięty z cache do osobnego, małego żądania zwracającego JSON z jedną liczbą, a nie blok HTML. Podmieniany jest sam
textContentwęzła z licznikiem. BezreplaceWith, bez rekonstrukcji poddrzewa. - Handler przycisku przepisany tak, żeby najpierw oddał klatkę. Zmiana stanu przycisku na „dodaję” dzieje się natychmiast, cała reszta po
await scheduler.yield()tam, gdzie API jest dostępne, a w fallbacku porequestAnimationFrameplus zadaniu makro. Użytkownik dostaje odpowiedź wizualną w pierwszej klatce, a sieć i tak potrzebuje swoich milisekund.
Kompromis jest realny i warto go nazwać: licznik koszyka na stronach bez fragmentów aktualizuje się dopiero po nawigacji. Dla sklepu, w którym ktoś dodaje produkt z poziomu artykułu blogowego, to regres funkcjonalny. Trzeba świadomie zdecydować, czy ta ścieżka istnieje w danym sklepie, a nie kopiować wyłączenie fragmentów z pierwszego lepszego wątku na forum.
Jak znaleźć, co dokładnie zżera główny wątek
Zgadywanie tutaj kosztuje najwięcej czasu, więc zanim cokolwiek ruszyliśmy, poszły dwa pomiary.
Long Animation Frames API. PerformanceObserver z typem long-animation-frame zwraca nie tylko długość klatki, ale też scripts[] z invoker i sourceURL. To jest różnica między „coś blokuje wątek przez 300 ms” a „konkretny plik trackera wykonał 280 ms pracy w handlerze pointerdown”. LoAF zastąpił w praktyce zgadywanie po Long Tasks, bo Long Tasks nie mówią, czyj to kod.
Biblioteka web-vitals w wariancie attribution. Zwraca dla INP nazwę elementu docelowego, fazę, w której poszedł czas (opóźnienie wejścia, przetwarzanie, opóźnienie prezentacji), oraz loafScripts. Ta jedna informacja ustawia kierunek naprawy. Jeśli czas siedzi w fazie prezentacji, problemem jest rozmiar i złożoność renderu, a nie skrypt. Jeśli w fazie wejścia, wątek był zajęty czymś, co zaczęło się wcześniej, więc winowajcą jest zwykle ładowanie skryptów zewnętrznych, a nie ten konkretny przycisk.
U tego klienta rozkład wyszedł nierówny: kliknięcie w menu mobilne miało czas głównie w opóźnieniu wejścia (skrypty startujące w tle), a „dodaj do koszyka” w przetwarzaniu (własny handler plus fragmenty). Jedna metryka, dwie różne przyczyny, dwie różne poprawki. To najczęstszy powód, dla którego uniwersalna wtyczka „napraw INP” nie działa.
Skrypty zewnętrzne: Partytown pomaga, ale nie jest darmowy
Widgety czatu, Pixel, Tag Manager i Hotjar walczyły o ten sam wątek co interfejs. Przeniesienie ich do Web Workera przez Partytown zdejmuje tę pracę z wątku głównego, bo worker ma własny.
Rzeczy, o których dokumentacja mówi cicho, a które w sklepie decydują o powodzeniu:
- Partytown proxuje dostęp do DOM przez synchroniczne żądania XHR do własnego endpointu. To wymaga poprawnie wystawionego katalogu
~partytownz właściwymContent-Type, a na Cloudflare Pages i przy agresywnych regułach cache ten katalog potrafi zostać zablokowany. Objaw: tracker po prostu przestaje zbierać dane, cicho. - Skrypty, które muszą czytać
document.cookielub wywoływać funkcje po kliknięciu w tym samym zadaniu, w workerze zachowują się inaczej. Konwersje w narzędziach reklamowych bywają zaniżone, bo część zdarzeń nie zdąży przed nawigacją. - Widget czatu, który po załadowaniu wstrzykuje własny iframe i nasłuchuje na całym dokumencie, zwykle nie nadaje się do workera. Tutaj rozwiązaniem było opóźnienie jego startu do pierwszej interakcji użytkownika, a nie przeniesienie.
Decyzja praktyczna: do workera poszły Tag Manager i Hotjar, Pixel został na wątku głównym, ale ładowany po pierwszym zdarzeniu pointerdown lub scroll. Czat startuje dopiero po kliknięciu we własny, statyczny przycisk, który renderuje się jako zwykły HTML i nie czeka na nic.
Jeśli zespół marketingowy nie zaakceptuje ryzyka niedoliczonych zdarzeń, to nie jest decyzja techniczna, tylko biznesowa, i trzeba ją podjąć przed wdrożeniem, a nie po pierwszym raporcie z kampanii.
LCP: slider był objawem, nie chorobą
Na stronie głównej siedział Revolution Slider ciągnący kilka megabajtów JavaScriptu zanim pokazał pierwszy piksel. Usunięcie go i zastąpienie statycznym układem na CSS Grid to najbardziej opłacalna zmiana w całym projekcie, ale warto zobaczyć, dlaczego.
Slider nie tylko waży. Slider przesuwa moment, w którym przeglądarka w ogóle dowiaduje się o adresie obrazu. Dopóki źródło grafiki żyje w konfiguracji JavaScriptu, skaner wstępny parsera HTML go nie widzi, a to on jest jedynym mechanizmem, który pobiera zasoby równolegle z parsowaniem. Statyczny <img> w HTML jest widoczny dla skanera natychmiast.
Do tego trzy zmiany, które razem odpowiadają za resztę:
fetchpriority="high"na obrazie nagłówkowym. WordPress od 6.3 potrafi ustawić to sam przezwp_get_loading_optimization_attributes, ale heurystyka myli się, gdy obraz jest wstawiany przez builder albo siedzi w bloku z opóźnionym renderem. Warto sprawdzić w źródle strony, czy atrybut faktycznie wyszedł, zamiast zakładać.- Usunięcie
loading="lazy"z obrazu nagłówkowego. To jest najczęstszy własny gol w tej kategorii: wtyczka optymalizacyjna dokłada leniwe ładowanie do wszystkiego, łącznie z elementem, który jest kandydatem na LCP, przez co pobranie startuje dopiero po pierwszym layoucie. - AVIF zamiast PNG dla zdjęć produktowych i nagłówkowych. Obraz nagłówkowy zszedł z 800 KB do około 45 KB przy tej samej percepcyjnej jakości. Koszt: kodowanie AVIF jest wolne, więc generowanie miniatur przy wgrywaniu produktów hurtowo potrafi wywalić limit czasu PHP. Tutaj konwersja poszła poza WordPressem, wsadowo, a wtyczka tylko serwuje gotowe pliki z negocjacją po nagłówku
Accept.
CLS zaczyna się w typografii, nie w obrazkach
Przesunięcia układu miały dwa źródła. Obrazy bez wymiarów to część oczywista i zamyka ją aspect-ratio na kontenerze plus jawne width i height w znaczniku. Część mniej oczywista to czcionka.
Tekst pojawiał się w foncie systemowym, potem wskakiwał font właściwy o innej metryce i cała kolumna przesuwała się o kilkanaście pikseli. Mamy tu dwie drogi i obie są legalne:
font-display: optionalw połączeniu z<link rel="preload">. Jeśli font nie zdąży w oknie około 100 ms, przeglądarka zostaje przy systemowym na całe wczytanie strony. Zero przeskoku, ale część użytkowników na wolnym łączu nigdy nie zobaczy Twojej typografii. Dla sklepu z mocną identyfikacją wizualną to bywa nie do przyjęcia.font-display: swapplussize-adjust,ascent-overrideidescent-overridena deklaracji fontu zastępczego, tak żeby metryki fontu systemowego odpowiadały docelowemu. Przeskok nadal następuje, ale nie zmienia wysokości linii, więc nie generuje przesunięcia układu.
Wybraliśmy drugą drogę, bo klient miał mocno rozpoznawalny krój w identyfikacji. Wymagało to wyliczenia wartości nadpisań dla konkretnej pary fontów, co jest jednorazową pracą na kilkanaście minut i trzeba ją powtórzyć po każdej zmianie kroju.
Trzecie źródło, znalezione dopiero w polu: baner zgody na pliki cookie wstrzykiwany po załadowaniu, bez zarezerwowanego miejsca. W laboratorium nie było go widać, bo skrypt zgody był blokowany przez blokadę w środowisku testowym. Dane z CrUX pokazywały CLS znacznie wyższy niż lokalny pomiar i to była różnica warta pościgu.
Serwer i cache: gdzie naprawdę siedzi czas odpowiedzi
Wynik w laboratorium można ugrać samą warstwą frontową. Danych polowych nie, bo one zawierają czas odpowiedzi serwera dla użytkowników rozsianych geograficznie.
- Obiektowy cache w Redis. Bez niego każda podstrona wykonuje dziesiątki zapytań po opcje, menu i meta produktów. Warto sprawdzić, czy wtyczka cache obiektowego faktycznie działa, a nie tylko jest zainstalowana:
wp cache typew WP-CLI odpowiada wprost. - Cache HTML na krawędzi z regułą pomijania po ciasteczkach. Klucz to obejście cache dla żądań niosących
woocommerce_items_in_cart,woocommerce_cart_hashiwp_woocommerce_session_, bo inaczej użytkownik dostanie cudzy koszyk. To jest błąd, który zdarza się raz i kosztuje zaufanie sklepu na długo. - Sesje WooCommerce. Domyślnie żyją w tabeli
wp_woocommerce_sessionsi przy dużym ruchu botów potrafią urosnąć do setek tysięcy wierszy, co spowalnia każde żądanie, także to od fragmentów koszyka. Czyszczenie starych sesji i przeniesienie ich do Redis zdejmuje ten koszt. - PHP i baza. PHP 8.3 z włączonym OPcache i preloadingiem daje wyraźnie krótszy czas wykonania niż PHP 7.4, na którym sklep stał. Aktualizacja wersji PHP to często największy pojedynczy zysk w czasie odpowiedzi, a bywa odkładana, bo ktoś kiedyś wpisał do dokumentacji, że jedna wtyczka nie jest zgodna.
Czas odpowiedzi serwera zszedł z około 600 ms do poziomu dziesiątek milisekund dla treści serwowanej z krawędzi. Ta liczba jest mierzalna przez każdego, kto ma dostęp do zakładki sieć w narzędziach przeglądarki, i dlatego ją podajemy.
Dwie rzeczy, których świadomie nie zrobiliśmy
Krytyczny CSS generowany dla każdej karty produktu osobno był na liście i wypadł. Sklep miał kilka tysięcy produktów w kilkunastu układach szablonu, więc utrzymanie takiego zbioru wymagałoby własnego procesu regeneracji po każdej zmianie w motywie, a pierwsza pominięta regeneracja daje stronę bez stylów w widocznym obszarze. Zamiast tego arkusz został podzielony po komponentach i wczytywany warunkowo przez wp_enqueue_style z zależnością od tego, co faktycznie renderuje szablon, a reguły dla obszaru nad zgięciem trafiły do jednego małego bloku wspólnego dla wszystkich typów widoku. Mniej efektowne, za to nie psuje się samo w piątek wieczorem.
Druga odrzucona ścieżka to przepisanie sklepu na warstwę frontową odseparowaną od WordPressa. Rozwiązuje część problemów z wątkiem głównym, ale wprowadza nową klasę kosztów: podwójne utrzymanie szablonów, własną obsługę koszyka i sesji, oraz zależność od zespołu, który umie w to wejść. Dla sklepu, który obsługuje jedna osoba plus agencja na godziny, to zamiana problemu wydajnościowego na problem organizacyjny. Granica jest prosta: dopóki czas odpowiedzi serwera i główny wątek dają się posprzątać w istniejącym stosie, nie zmieniaj stosu.
Obie decyzje zapisaliśmy w dokumentacji projektu razem z powodem, bo za pół roku ktoś zapyta, czemu nie ma krytycznego CSS, i odpowiedź „zapomnieliśmy” jest gorsza niż jakakolwiek inna.
Speculation Rules, czyli zero zamiast szybko
Ostatnia warstwa to nie optymalizacja, tylko przesunięcie pracy w czasie. Speculation Rules API pozwala zadeklarować w JSON, że przeglądarka ma pobrać albo wręcz wyrenderować dokument, zanim użytkownik kliknie.
Konfiguracja, która ma sens w sklepie:
- Prerender z regułą
eagerness: "moderate"dla linków do kart produktów. Uruchamia się na najechaniu kursorem lub przytrzymaniu palca, więc nie renderuje wszystkiego w tle. - Prefetch dla paginacji i filtrów kategorii, gdzie prerender byłby marnotrawstwem.
- Wykluczenie wszystkiego, co zmienia stan. Linki do koszyka, kasy, wylogowania i jakiekolwiek adresy z parametrem
add-to-cartmuszą trafić dowhere.not. Prerenderowanie adresu, który dodaje produkt do koszyka, to nie teoria, tylko realny sposób na zamówienia widmo.
Prerender w Chrome działa na warunkach przeglądarki, między innymi przy oszczędzaniu danych i niskim poziomie baterii jest pomijany. Traktuj go jako ulepszenie dla części użytkowników, nie jako fundament architektury. Wtyczka Speculative Loading dostarczana z WordPressem ustawia rozsądne domyślne wartości, ale jej reguły wykluczeń trzeba rozszerzyć o adresy specyficzne dla WooCommerce.
Czego ten case study nie udowadnia
Wynik 100 w Lighthouse to pomiar laboratoryjny na symulowanym łączu i jednej konfiguracji urządzenia. Jest użyteczny jako sito, nie jako dowód. Dowodem są dane polowe w CrUX, liczone na 75. percentylu z okna 28 dni, i one aktualizują się z opóźnieniem. Jeśli ktoś pokazuje zmianę w danych polowych tydzień po wdrożeniu, pokazuje szum.
Nie podajemy tu przyrostu sprzedaży ani spadku odrzuceń, bo nie potrafimy oddzielić wpływu wydajności od sezonu, zmian w kampaniach i aktualizacji algorytmu, które szły w tym samym okresie. Mechanizm, który znamy i możemy opisać, wygląda tak: szybsze wczytanie zmniejsza liczbę porzuceń jeszcze przed wyrenderowaniem strony, a krótszy INP usuwa podwójne kliknięcia w przycisk zamówienia. To są łańcuchy przyczynowe, które da się pokazać w nagraniach sesji. Konkretny procent przychodu przypisany do prędkości to już model, a nie pomiar, i uczciwiej nazwać go po imieniu.
Co warto skopiować z tej pracy niezależnie od stosu: zacznij od danych polowych, nie od Lighthouse. Znajdź element, w który ludzie klikają najczęściej, zmierz jego INP z atrybucją, i dopiero wtedy decyduj, czy problem jest w Twoim kodzie, w cudzym skrypcie, czy w czasie odpowiedzi serwera. Trzy różne przyczyny, trzy różne rachunki.
Jeśli chcesz, żebyśmy przeszli tę drogę na Twoim sklepie, zobacz nasze usługi optymalizacji szybkości WordPress.







