Strona po wygenerowaniu ma jeden obraz hero, jeden krój pisma i dwa skrypty analityczne, a w Lighthouse na profilu mobilnym LCP przekracza 4 s. Cały dokument razem z zasobami waży mniej niż pół megabajta. Problemem nie jest waga, tylko moment, w którym przeglądarka dowiaduje się, co ma pobrać.
Progi są sztywne: LCP poniżej 2,5 s to „dobrze”, powyżej 4,0 s to „słabo”, a ocena zapada na 75. percentylu wizyt, nie na średniej (dokumentacja Core Web Vitals w web.dev, stan na sierpień 2026). CLS analogicznie: 0,1 i 0,25. Generatory stron potrafią wyprodukować kod, który mieści się w budżecie bajtów i mimo to ląduje w czerwonym zakresie, bo powtarzają trzy konkretne wzorce. Każdy z nich ma inny mechanizm i inną poprawkę.
Cztery odcinki LCP i ten, w którym siedzi problem
LCP rozkłada się na cztery rozłączne odcinki: TTFB, opóźnienie odkrycia zasobu (resource load delay), czas pobierania zasobu (resource load duration) i opóźnienie renderu elementu (element render delay). Ten podział jest widoczny w DevTools w panelu Performance oraz w bibliotece web-vitals w wariancie z atrybucją. Dokumentacja web.dev podaje orientacyjny cel: odcinek odkrycia zasobu powinien zajmować mniej niż 10% całego LCP.
Wzorce z generatorów uderzają prawie zawsze w odcinek drugi i czwarty, czyli w te, które nie mają nic wspólnego z wielkością pliku. Optymalizacja obrazka z 300 kB do 90 kB skraca odcinek trzeci, a on często i tak nie jest wąskim gardłem.
| Odcinek LCP | Co się w nim dzieje | Cel orientacyjny | Co go psuje w kodzie z generatora |
|---|---|---|---|
| TTFB | Od nawigacji do pierwszego bajtu HTML | ok. 40% budżetu LCP | Brak cache, ciężki motyw, przekierowanie z www |
| Resource load delay | Od TTFB do startu pobierania obrazu LCP | poniżej 10% | Hero jako background-image, obraz za skryptem blokującym |
| Resource load duration | Pobieranie samego obrazu | ok. 40% | PNG zamiast AVIF/WebP, brak srcset |
| Element render delay | Od końca pobierania do namalowania | poniżej 10% | Font blokujący tekst, JS zajmujący wątek główny |
Zanim cokolwiek zmienisz, sprawdź w DevTools, który element jest elementem LCP i jak wygląda ten rozkład. Jeśli LCP to nagłówek tekstowy, a nie obraz, odcinki drugi i trzeci wynoszą zero i cała robota jest po stronie fontu oraz skryptów.
Hero jako div z background-image: obraz odkryty po CSS
Najczęstszy wzorzec wygenerowanej sekcji powitalnej wygląda tak:
<section class="hero">
<h1>Projektujemy strony, które sprzedają</h1>
</section>
<style>
.hero {
min-height: 70vh;
background-image: url("/assets/hero-1920.jpg");
background-size: cover;
}
</style>
Przeglądarka ma skaner wstępnego wczytywania, który czyta surowy HTML równolegle z głównym parserem i wyciąga z niego adresy zasobów: src, srcset, href w znaczniku link. Adres schowany w regule CSS jest dla tego skanera niewidoczny. Żeby go poznać, przeglądarka musi pobrać arkusz, sparsować go, zbudować drzewo DOM, dopasować regułę do elementu i stwierdzić, że element w ogóle powstanie. Dopiero wtedy startuje żądanie o obraz.
W praktyce dokłada to do odcinka „resource load delay” pełną rundę: TTFB dokumentu plus pobranie CSS plus czas na konstrukcję drzewa. Przy wolnym łączu mobilnym to kilkaset milisekund, przy arkuszu z zewnętrznego hosta bez preconnect jeszcze więcej, bo dochodzi DNS i uzgadnianie TLS.
Wersja, która skraca tę ścieżkę:
<section class="hero">
<img
src="/assets/hero-1280.avif"
srcset="/assets/hero-800.avif 800w,
/assets/hero-1280.avif 1280w,
/assets/hero-1920.avif 1920w"
sizes="100vw"
width="1920" height="1080"
alt=""
fetchpriority="high"
decoding="async"
class="hero__img">
<h1 class="hero__title">Projektujemy strony, które sprzedają</h1>
</section>
<style>
.hero { position: relative; min-height: 70vh; }
.hero__img {
position: absolute; inset: 0;
width: 100%; height: 100%;
object-fit: cover;
}
.hero__title { position: relative; z-index: 1; }
</style>
Trzy rzeczy dzieją się tu naraz. Adres trafia do atrybutu, więc skaner znajduje go w pierwszym przebiegu po HTML. Atrybut fetchpriority="high" podnosi priorytet żądania od razu, zamiast czekać, aż przeglądarka po obliczeniu układu ustali, że obraz jest w widocznym obszarze, i dopiero wtedy przesunie go w kolejce. Wpisane width i height rezerwują miejsce, co zabiera powód do przesunięcia układu.
Jeśli tło musi zostać tłem, bo tak działa szablon, minimum to jawne wskazanie zasobu w nagłówku dokumentu:
<link rel="preload" as="image"
href="/assets/hero-1280.avif"
imagesrcset="/assets/hero-800.avif 800w, /assets/hero-1280.avif 1280w"
imagesizes="100vw"
fetchpriority="high">
To działa, ale trzyma ten sam URL w dwóch miejscach. Przy podmianie grafiki w panelu jedno z nich zwykle zostaje stare i wtedy strona pobiera dwa obrazy zamiast jednego.
Font bez preload i z domyślnym font-display
Drugi wzorzec to krój pisma podpięty przez zewnętrzny arkusz albo, w gorszym wariancie, przez regułę @import:
<style>
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=auto");
</style>
Łańcuch zależności ma tu trzy ogniwa i dwa hosty: HTML pobiera arkusz strony, arkusz strony pobiera arkusz z fontami, ten dopiero wskazuje plik woff2. Każde ogniwo zaczyna się po zakończeniu poprzedniego, a dwa skoki na obcy host wymagają DNS i TLS. Wariant z @import jest najgorszy, bo zewnętrzny arkusz nie jest nawet widoczny dla skanera wstępnego wczytywania.
Do tego dochodzi font-display. Wartość domyślna (auto, traktowana jak block) każe przeglądarce wstrzymać rysowanie tekstu na okres blokujący, w Chrome i Firefoksie około 3 s. Gdy elementem LCP jest nagłówek, ten czas ląduje w całości w odcinku „element render delay”. Strona z pustym miejscem tam, gdzie ma być nagłówek, nie ma czego zaraportować jako LCP.
Wersja po zmianie, z fontem hostowanym lokalnie:
<link rel="preload" as="font" type="font/woff2"
href="/fonts/inter-400.woff2" crossorigin>
<style>
@font-face {
font-family: "Inter";
src: url("/fonts/inter-400.woff2") format("woff2");
font-weight: 400;
font-display: swap;
}
@font-face {
font-family: "Inter Fallback";
src: local("Arial");
size-adjust: 107%;
ascent-override: 90%;
descent-override: 22%;
line-gap-override: 0%;
}
body { font-family: "Inter", "Inter Fallback", sans-serif; }
</style>
Atrybut crossorigin przy preloadzie fontu nie jest ozdobą. Bez niego przeglądarka pobiera plik dwa razy, bo żądanie z preloadu i żądanie z reguły @font-face mają różne tryby CORS i nie trafiają w ten sam wpis pamięci podręcznej.
Wartości size-adjust i ascent-override w powyższym kodzie są przykładowe. Trzeba je policzyć dla konkretnej pary krój docelowy plus krój zastępczy, na przykład narzędziem porównującym metryki fontów, i sprawdzić wynik przez nałożenie zrzutów przed i po zamianie. Bez tego swap zamienia problem z LCP na problem z CLS: tekst pojawia się szybciej, ale przeskakuje w momencie podmiany kroju.
Skrypty analityczne w head: parser stoi, a hero czeka
Trzeci wzorzec to tag analityczny wklejony synchronicznie, zwykle jako pierwsza rzecz w sekcji head, żeby „nie zgubić zdarzeń”:
<head>
<script src="https://www.googletagmanager.com/gtag/js?id=G-XXXX"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', 'G-XXXX');
</script>
<link rel="stylesheet" href="/style.css">
</head>
Skrypt bez atrybutu zatrzymuje parser HTML do czasu pobrania i wykonania pliku. Konsekwencje są dwie. Po pierwsze, konkuruje o pasmo z arkuszem stylów i z obrazem hero, a stoi w kolejce przed nimi. Po drugie, jego wykonanie zajmuje wątek główny, który w tym samym czasie jest potrzebny do obliczenia układu i pierwszego malowania, co powiększa „element render delay”. Menedżer tagów zwykle dociąga jeszcze kolejne skrypty, więc jeden wpis w head generuje łańcuch żądań, którego nie widać w kodzie strony.
<head>
<link rel="preload" as="font" type="font/woff2" href="/fonts/inter-400.woff2" crossorigin>
<link rel="stylesheet" href="/style.css">
</head>
<body>
<!-- treść strony -->
<script>
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', 'G-XXXX');
addEventListener('load', () => {
requestIdleCallback(() => {
const s = document.createElement('script');
s.src = 'https://www.googletagmanager.com/gtag/js?id=G-XXXX';
s.async = true;
document.head.appendChild(s);
});
});
</script>
</body>
Kolejka dataLayer powstaje od razu, więc zdarzenia z początku wizyty nie giną, a sam plik biblioteki dociąga się po zdarzeniu load, w czasie bezczynności wątku głównego. Wariant łagodniejszy, gdy zespół nie chce odraczać aż tak mocno: sam atrybut defer na tagu w sekcji head. Odblokowuje parser, choć nadal zajmuje pasmo w trakcie ładowania.
Jedno zastrzeżenie: skrypty testów A/B i banery zgody zachowują się inaczej. Odroczenie skryptu, który podmienia treść nad zgięciem, daje migotanie i przesunięcie układu. Takie skrypty albo zostają synchroniczne i są utrzymywane w minimalnym rozmiarze, albo ich efekt jest renderowany po stronie serwera.
CLS wychodzi z tych samych trzech miejsc
Zestaw przyczyn jest wspólny, więc naprawy też się nakładają:
- Obraz bez
widthiheightalbo bezaspect-ratiow CSS. Układ liczy się dwa razy: przed pobraniem obrazu i po nim. - Ustawienie
font-display: swapbez dopasowanych metryk kroju zastępczego. Nagłówek zmienia liczbę linii przy podmianie kroju i wszystko poniżej jedzie w dół. - Baner zgody albo pasek promocyjny wstrzykiwany skryptem na górę dokumentu po pierwszym malowaniu. Poprawka to rezerwacja miejsca w HTML albo wyświetlenie go jako warstwy z
position: fixed, która nie uczestniczy w przepływie.
Kolejność napraw i sposób weryfikacji
Kolejność ma znaczenie, bo poprawki z dolnych wierszy maskują te z górnych. Jeśli najpierw skompresujesz obraz, a dopiero potem odblokujesz parser, wynik pierwszej zmiany zmierzysz w warunkach, które i tak się zmienią.
| Krok | Zmiana | Odcinek LCP | Weryfikacja |
|---|---|---|---|
| 1 | Zdjęcie skryptów blokujących z sekcji head |
load delay, render delay | Zakładka Network, kolumna Priority; brak pozycji „render-blocking” w Lighthouse |
| 2 | Hero z background-image przeniesione na z fetchpriority |
load delay | Obraz w pierwszych żądaniach po dokumencie, priorytet High |
| 3 | Preload fontu, font-display: swap, metryki kroju zastępczego |
render delay | Panel Performance, moment pierwszego rysowania tekstu |
| 4 | AVIF lub WebP plus srcset |
load duration | Rozmiar odpowiedzi dla wybranego wariantu |
| 5 | Cache i kompresja odpowiedzi HTML | TTFB | Czas do pierwszego bajtu w zakładce Network |
Lighthouse uruchamiany lokalnie ma rozrzut między przebiegami, bo dławienie sieci i procesora jest symulowane na maszynie, która robi w tle inne rzeczy. Sensowna procedura to pięć przebiegów w trybie mobilnym, w oknie prywatnym, bez rozszerzeń, i porównanie median. Sprawdź przy tym, czy element LCP nie zmienił się po poprawce, bo wtedy porównujesz dwie różne rzeczy.
Dane z pola są rozstrzygające i wolniejsze. Raport CrUX w PageSpeed Insights i w Search Console pracuje na oknie 28 dni, więc pełny efekt wdrożenia widać dopiero po około miesiącu, a częściowy wcześniej. Kto nie chce czekać w ciemno, wpina bibliotekę web-vitals w wariancie z atrybucją i wysyła do własnego zbioru nie tylko wartość LCP, ale też nazwę elementu i rozkład na cztery odcinki. Wtedy widać, czy u realnych użytkowników wąskim gardłem jest to samo, co w laboratorium.
Lista kontrolna do przejścia przed publikacją: element LCP zidentyfikowany, obraz hero w znaczniku z fetchpriority="high", wymiary lub aspect-ratio na każdym obrazie nad zgięciem, font hostowany lokalnie z preloadem i atrybutem crossorigin, font-display ustawiony jawnie, metryki kroju zastępczego policzone, zero skryptów bez defer lub async w sekcji head, miejsce na baner zgody zarezerwowane.
Najważniejsza decyzja nie dotyczy jednej strony. Te trzy wzorce wracają przy każdym kolejnym generowaniu, więc ręczna poprawka po fakcie to praca wykonywana w kółko. Wpisz reguły do szablonu, z którego korzysta generator, a w potoku budowania dodaj krok, który odrzuca wynik, gdy w sekcji hero jest background-image, gdy w head siedzi skrypt bez defer albo gdy brakuje preloadu fontu. Dopóki ta kontrola nie jest zautomatyzowana, LCP będzie wracał powyżej 4 s przy każdej nowej podstronie.