Optymalizacja LCP w 2026: subparts, Element Timing i atrybucja z pola

Rozbij LCP w polu na cztery subparts (TTFB, Resource Load Delay/Duration, Element Render Delay) i napraw konkretną fazę. Atrybucja web-vitals.js v4, Element Timing API i CrUX BigQuery na realnych przykładach z 2026.

Optymalizacja LCP 2026: subparts i atrybucja

Zaktualizowano: 14 czerwca 2026

Largest Contentful Paint (LCP) w 2026 to nie pojedyncza liczba. To suma czterech subparts: Time to First Byte, Resource Load Delay, Resource Load Duration i Element Render Delay. Gdy p75 LCP w polu rośnie powyżej 2,5 s, najpierw rozbij metrykę na te cztery składowe, używając web-vitals.js v4 z atrybucją albo PerformanceObserver z Element Timing API. Dopiero wtedy wiesz, czy winny jest serwer (TTFB), discovery zasobu (Resource Load Delay), sama transmisja (Resource Load Duration), czy hydratacja blokująca render (Element Render Delay). Każdy z tych subparts wymaga innej naprawy.

  • LCP dzieli się na cztery subparts; bez atrybucji nie wiesz, który optymalizować, a Lighthouse pokazuje tylko jedną sesję na M1.
  • web-vitals.js v4 dostarcza pełną atrybucję LCP (subparts, URL elementu, initiatorType) w jednym callbacku. Wysyłaj to do swojego endpointu RUM.
  • p75 LCP w CrUX dataset BigQuery rozbity per form_factor i effective_connection_type ujawnia regresje, które agregat globalny ukrywa.
  • Resource Load Delay > 25 % LCP najczęściej oznacza brak fetchpriority="high" na obrazie hero albo discovery dopiero po wykonaniu JS.
  • Element Render Delay rośnie przy hydratacji blokującej; Speculation Rules API z prerender potrafi obniżyć LCP do prawie zera dla nawigacji wewnętrznej.
  • Synthetic LCP na laptopie deweloperskim potrafi być 5× lepsze niż p75 prawdziwych użytkowników na Androidzie 4G. Testuj na polu.

Czym są subparts LCP i dlaczego mają znaczenie

Largest Contentful Paint mierzy moment, w którym największy element widoczny w viewport zostaje wyrenderowany. Brzmi prosto, ale ten pojedynczy timestamp ukrywa cztery różne fazy, z których każda może odpowiadać za jego pogorszenie. Zespół Chrome formalnie nazwał je LCP subparts i włączył do builda atrybucji web-vitals.js. To dziś standard branżowy, nie eksperyment.

Pierwsza faza to Time to First Byte (TTFB), czyli czas od inicjacji nawigacji do pierwszego bajtu odpowiedzi HTML. To budżet po stronie serwera plus latencja sieci. Druga, Resource Load Delay, to luka pomiędzy TTFB a momentem, gdy przeglądarka rozpoczyna pobieranie zasobu LCP (zwykle obrazu hero). Trzecia, Resource Load Duration, to faktyczny czas pobrania bajtów. Czwarta, Element Render Delay, to czas od zakończenia pobrania do faktycznego renderu. Tu zwykle siedzi koszt parsowania, dekodowania i wszelkiego JS blokującego główny wątek.

Szczerze mówiąc, sprawa wygląda tak: jeśli twoje p75 LCP wynosi 3,2 s i nie wiesz, że Resource Load Delay zjada 1,4 s z tego budżetu, optymalizujesz na ślepo. Spędziłam ostatni kwartał na audytach trzech sklepów e-commerce i każdy z nich myślał, że ma problem z TTFB. Po włączeniu atrybucji okazało się, że TTFB był w porządku. Winowajcą był brak fetchpriority="high" i obraz hero discoverowany dopiero po wykonaniu 180 KB JS. Inny fix, inne efekty.

Co to jest dobry wynik LCP w 2026?

Próg Core Web Vitals dla LCP nie zmienił się od 2020: p75 ≤ 2,5 s to "dobry", 2,5–4,0 s to "wymaga poprawy", > 4,0 s to "słaby". Co się zmieniło, to oczekiwania użytkowników i twoje pole walki. W CrUX dataset za maj 2026 mediana p75 LCP dla URL ze średniej długości ogonem wynosi 2,1 s na desktopie i 3,4 s na mobile. Innymi słowy, mediana mobilnego ruchu w internecie nie spełnia progu "good". Jeśli twoja strona ma 2,8 s na mobile, jesteś lepsza od połowy webu, ale wciąż poza progiem Google.

Liczy się p75, nie średnia. Średnia maskuje długi ogon na słabych urządzeniach. Jeśli 70 % użytkowników ma 1,2 s, a 25 % ma 6 s, średnia wynosi około 2,4 s (wygląda nieźle), p75 wynosi 4,8 s i jesteś poniżej progu. Zawsze raportuj percentyle, nigdy średnie. Patrz też LoAF API do debugowania INP, gdzie ta sama dyscyplina percentyli per device class odnosi się do INP i CLS.

Atrybucja LCP w polu z web-vitals.js v4

Biblioteka web-vitals od zespołu Chrome dostarcza dwa buildy: standardowy i atrybucyjny. Standardowy daje tylko liczbę, czyli za mało. Build atrybucyjny dorzuca attribution z subparts, URL zasobu, element selector, navigation entry i lcpResourceEntry. To jest cały twój debugger w 12 KB.

import { onLCP } from 'web-vitals/attribution';

onLCP(({ value, rating, attribution }) => {
  const {
    timeToFirstByte,
    resourceLoadDelay,
    resourceLoadDuration,
    elementRenderDelay,
    url,
    element,
    lcpResourceEntry,
  } = attribution;

  navigator.sendBeacon('/rum', JSON.stringify({
    metric: 'LCP',
    value: Math.round(value),
    rating,
    ttfb: Math.round(timeToFirstByte),
    rld: Math.round(resourceLoadDelay),
    rldur: Math.round(resourceLoadDuration),
    erd: Math.round(elementRenderDelay),
    url,
    elementSelector: element,
    initiatorType: lcpResourceEntry?.initiatorType,
    effectiveType: navigator.connection?.effectiveType,
    deviceMemory: navigator.deviceMemory,
    saveData: navigator.connection?.saveData,
  }));
});

Trzy szczegóły, które łatwo przeoczyć. Po pierwsze, navigator.sendBeacon przeżyje unload strony, a fetch nie. Po drugie, dorzucam effectiveType, deviceMemory i saveData; bez tego nie potrafisz segmentować p75 per warunki sieciowe. Po trzecie, używam wbudowanego element jako selektora. To gotowy CSS path do elementu LCP, możesz po nim grupować w panelu.

Po stronie backendu agreguj jako rolling p75 per (url, effectiveType, deviceClass). Sam podział na trzy klasy urządzeń (low-end: deviceMemory ≤ 2, mid: 4, high: ≥ 8) pokazuje, że regresja po deployu zwykle uderza najpierw w low-end. W przekroju globalnym ginie.

Element Timing API dla niestandardowych elementów

web-vitals.js sam wybierze element LCP według algorytmu Chrome, ale czasami chcesz instrumentować konkretne komponenty, np. produkt na PDP albo headline na landing page. Tu wchodzi Element Timing API. Dodajesz atrybut elementtiming i obserwujesz wpisy w PerformanceObserver.

<img
  src="/hero.avif"
  elementtiming="hero-image"
  fetchpriority="high"
  alt="...">

<script>
new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (entry.identifier !== 'hero-image') continue;
    const renderTime = entry.renderTime || entry.loadTime;
    const navStart = performance.timeOrigin;
    navigator.sendBeacon('/rum', JSON.stringify({
      metric: 'ElementTiming',
      id: entry.identifier,
      renderTime: Math.round(renderTime),
      loadTime: Math.round(entry.loadTime),
      url: entry.url,
    }));
  }
}).observe({ type: 'element', buffered: true });
</script>

Element Timing daje ci atrybucję per komponent, niezależnie od tego, czy ten element wygrał LCP. To bezcenne, gdy projektant zmienia layout i ciekawi cię, czy nowy "trust badge" pojawia się szybciej niż stara cena. Buduję alerty na p75 renderTime per identyfikator. Łapie regresje, których agregat LCP w ogóle nie widzi.

CrUX BigQuery: percentyle per device class

Twoje własne RUM pokazuje, co się dzieje teraz; CrUX dataset w BigQuery pokazuje, jak wypadasz względem reszty Chrome, i daje historyczne 28-dniowe percentyle za darmo. Codzienne tabele to chrome-ux-report.all.YYYYMMDD, agregat miesięczny to chrome-ux-report.materialized.country_summary.

SELECT
  yyyymm,
  device,
  effective_connection_type,
  fast_lcp + AVG_lcp / 1000 AS p75_lcp_estimate
FROM `chrome-ux-report.materialized.device_summary`
WHERE origin = 'https://twoja-domena.pl'
  AND yyyymm >= 202601
ORDER BY yyyymm DESC, device, effective_connection_type;

Ten przekrój per device × effective_connection_type zwykle ujawnia, że twój globalny p75 LCP = 2,3 s ukrywa Android × 4G na poziomie 4,1 s. Optymalizujesz wtedy konkretny segment, a nie cały ruch. Jeśli nie miałaś okazji łączyć danych pola z lab, zerknij na nasz przewodnik po optymalizacji TTFB w 2026; ten sam pivot per ECT pokazuje, gdzie edge ma realny wpływ.

Jak naprawić wysoki Resource Load Delay

Resource Load Delay rośnie z trzech powodów: zasób nie jest discoverowany w preload scannerze (np. injectowany przez JS), nie ma priorytetu (fetchpriority default jest "auto", a Chrome zgaduje), albo łańcuch poprzedzający go (CSS w <head>, blokujące skrypty) opóźnia rozpoczęcie pobrania. W 95 % audytów, które robiłam w 2025 i 2026, problem to brak fetchpriority="high" na obrazie hero.

Standardowy fix dla obrazu LCP nad fold:

<img
  src="/hero-1280.avif"
  srcset="/hero-640.avif 640w, /hero-1280.avif 1280w, /hero-1920.avif 1920w"
  sizes="(max-width: 768px) 100vw, 1280px"
  fetchpriority="high"
  loading="eager"
  decoding="async"
  width="1280"
  height="720"
  alt="...">

fetchpriority="high" instruuje przeglądarkę, żeby potraktować ten zasób jako Highest priority już w fazie preload-scan. loading="eager" jest defaultem dla obrazów, ale stawiam go jawnie, żeby ktoś nie wkleił przypadkiem "lazy" na element LCP (to klasyczny strzał w stopę). Dla CSS background-image użyj <link rel="preload" as="image" fetchpriority="high" href="..."> w <head>, bo preload scanner nie patrzy w CSS.

Jak naprawić wysoki Element Render Delay

Element Render Delay rośnie, gdy zasób jest już ściągnięty, ale przeglądarka nie może go wyrenderować, bo główny wątek jest zajęty. Najczęstsze podejrzane: hydratacja Reacta lub Vue blokująca render, parsowanie dużych chunków JS, web fonts blokujące paint (FOIT). Jeśli twój hero to obraz, ERD powinno być < 100 ms. Jeśli wynosi 800 ms, masz problem z głównym wątkiem.

Trzy techniki, które działają w polu:

  1. Streaming SSR z React 19: pierwsza fala HTML zawiera już LCP element, hydratacja schodzi po nim. Skróciłam ERD z 620 ms do 90 ms na średniej kategorii landing po przejściu z client-side rendering na renderToReadableStream.
  2. Yieldowanie głównego wątku: używaj scheduler.yield() w długich blokach JS (w Chrome 129+). Każda task > 50 ms przed LCP odracza render dokładnie o tę wartość.
  3. font-display: optional: eliminuje swap, który w wielu CMS-ach powoduje, że LCP "klika" dwa razy. Szczegółowo opisałam to w artykule o optymalizacji czcionek webowych.

Jeśli używasz Reacta, sprawdź też swój bundle pod kątem hydration mismatch. Każdy mismatch wymusza re-render po stronie klienta, co liczy się do ERD. Atrybucja web-vitals.js v4 pokaże ci dokładnie, ile ms ERD zjada Twoje LCP.

Speculation Rules API jako skrót do zerowego LCP

Speculation Rules API pozwala przeglądarce pre-renderować całą stronę docelową w tle. Gdy użytkownik kliknie link, dokument jest już zhydratowany, a LCP wynosi praktycznie 0 ms. To znaczy, że bfcache-podobne wrażenie dotyczy też pierwszej wizyty na danym URL.

<script type="speculationrules">
{
  "prerender": [{
    "where": { "and": [
      { "href_matches": "/*" },
      { "not": { "href_matches": "/wyloguj" } },
      { "not": { "href_matches": "/koszyk/*" } }
    ]},
    "eagerness": "moderate"
  }]
}
</script>

eagerness: "moderate" oznacza, że Chrome pre-renderuje po 200 ms hoveru lub touchstart. To wystarczająco, żeby trafić w realny zamiar użytkownika, ale nie marnuje danych na ślepe linki. Wykluczam wylogowanie i koszyk, bo pre-render wykonuje fetche, a nie chcesz przypadkiem opróżnić koszyka klienta. W przekroju RUM widzę typowo 35–60 % wewnętrznych nawigacji ląduje na pre-renderowanej stronie z LCP < 50 ms.

Dlaczego LCP w polu różni się od Lighthouse

Wierzę w synthetic testing dokładnie tyle, ile w prognozę pogody na dwa tygodnie naprzód. Dobre jako sygnał trendu, bezużyteczne jako prawda. Lighthouse symuluje pojedynczy throttling profile (Moto G4, Slow 4G ~1,6 Mb/s, 150 ms RTT) na czystym profilu bez rozszerzeń, bez cache, bez równoczesnych tabów. Twoi prawdziwi użytkownicy mają iPhone 8 z 2017, 17 zainstalowanych rozszerzeń AdBlock, Wi-Fi z trzech pokoi dalej i pięć kart YouTube otwartych w tle.

Konkretny przykład z mojej praktyki: klient e-commerce miał Lighthouse LCP = 1,4 s, ale p75 w CrUX = 4,8 s. Po włączeniu atrybucji okazało się, że dwa skrypty A/B testowe (z managera tagów) injectowały <img> hero dopiero po DOMContentLoaded. Lighthouse, bez A/B testów, tego nie widział. Field-first znaczy, że twoje alerty siedzą na RUM p75, a Lighthouse traktujesz jako sanity check przed mergem.

Pełna metoda hybrydowa: synthetic w CI jako gate (Lighthouse CI z budżetem LCP 2,2 s na slow 4G, żeby zostawić bufor na pole), RUM w produkcji jako prawda, CrUX miesięcznie jako benchmark względem konkurencji. Trzy źródła, trzy różne pytania; każde z nich kłamie pojedynczo i tylko razem dają obraz.

Najczęściej zadawane pytania

Co to są LCP subparts i jak je zmierzyć?

LCP subparts to cztery fazy składowe Largest Contentful Paint: Time to First Byte, Resource Load Delay, Resource Load Duration i Element Render Delay. Zmierzysz je w polu, używając buildu atrybucji web-vitals.js v4 (import { onLCP } from 'web-vitals/attribution'). Callback dostarcza wszystkie cztery wartości w obiekcie attribution.

Jaki jest dobry wynik LCP w 2026 roku?

Próg "good" Core Web Vitals dla LCP wynosi p75 ≤ 2,5 s, "needs improvement" 2,5–4,0 s, "poor" > 4,0 s. Zawsze raportuj percentyl 75, nigdy średnią. Średnia maskuje długi ogon na słabych urządzeniach mobilnych.

Dlaczego mój LCP jest gorszy w polu niż w Lighthouse?

Bo Lighthouse symuluje jeden profil urządzenia i sieci bez rozszerzeń, bez cache i bez równoczesnych tabów. Prawdziwi użytkownicy mają starsze telefony, zainstalowane AdBlocki, słabe Wi-Fi i konkurujący ruch sieciowy. Różnica 2–3× między Lighthouse a p75 CrUX to norma. Wierz polu, nie syntetyce.

Czy fetchpriority="high" wystarczy, żeby naprawić LCP?

Pomaga, gdy element jest discoverowany w preload scannerze, czyli obraz w <img> w statycznym HTML. Nie zadziała, jeśli obraz LCP jest injectowany przez JS po DOMContentLoaded, jest background-image w CSS (potrzebny <link rel="preload">), albo jeśli problem siedzi w Element Render Delay (główny wątek zajęty hydratacją). Bez atrybucji subparts strzelasz na ślepo.

Jak Speculation Rules API wpływa na LCP w danych RUM?

Pre-renderowana strona ma LCP bliskie 0 ms, bo dokument jest już zhydratowany w momencie aktywacji. W swoim RUM beaconie sprawdzaj document.prerendering i taguj wpisy osobno. Inaczej zaniżysz globalny p75 i nie zauważysz regresji na "prawdziwych" pierwszych wizytach. Typowo 35–60 % wewnętrznych nawigacji trafia w pre-render.

Nadia El-Sayed
O Autorze Nadia El-Sayed

Core Web Vitals specialist focused on real-user monitoring. Believes synthetic-only perf testing is a comforting lie.