LCP powyżej 4 s po wygenerowaniu strony: hero, font i skrypty w head

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 width i height albo bez aspect-ratio w CSS. Układ liczy się dwa razy: przed pobraniem obrazu i po nim.
  • Ustawienie font-display: swap bez 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.