Jak sprawdzić szybkość strony w przeglądarce: TTFB, LCP, CLS

Szybkość strony w kodzie: TTFB, LCP, CLS i czas ładowania z API przeglądarki w Next.js, bez biblioteki, na działającym demie, które da się zepsuć.

Czas czytania: 7 min czytaniaOpublikowano:

Szybkość strony najprościej sprawdzić w samej przeglądarce, odczytując cztery liczby z jej Performance API: TTFB i czas ładowania z wpisu nawigacji strony (responseStart oraz loadEventEnd, gdy zdarzenie load już się skończy), a LCP i CLS z PerformanceObserver wywołanego z type i buffered: true. Oceniasz je progami z web.dev: LCP jest dobry do 2,5 s, CLS do 0,1, TTFB do 0,8 s. W aplikacji Next.js to jeden mały plik i komponent kliencki, bez biblioteki. To liczby laboratoryjne z jednego ładowania; jak wypadają prawdziwi odwiedzający polskich stron, pokazuje nasze badanie Core Web Vitals sklepów internetowych.

Measure page speed · Next.js 16.4.0Otwórz demoZobacz kod

Demo mierzy samo siebie, a jego dwa przyciski odgrywają w kodzie dwa klasyczne błędy. Insert banner wywołuje insertBanner, która ustawia licznik na 1000 ms, a potem renderuje nad liczbami baner, dla którego nie zarezerwowano miejsca: spóźnione przesunięcie dla CLS. Late hero to link do ?late, gdzie komponent renderuje duży akapit 3000 ms po starcie nawigacji: spóźnione malowanie dla LCP.

Które liczby oznaczają szybkość strony

„Szybkość strony” to kilka liczb i tylko część z nich to Core Web Vitals (otwiera się w nowej karcie), trzy wskaźniki, którymi Google opisuje ładowanie, szybkość reakcji i stabilność układu. Demo pokazuje cztery, które daje jedno ładowanie strony; INP wymaga interakcji.

WskaźnikCo mierzyGdzie przeglądarka go trzymaDobrySłabyCore Web Vital
TTFB (Time to First Byte)Czas do pierwszego bajtu HTMLWpis nawigacji, responseStartDo 0,8 sPowyżej 1,8 sNie
LCP (Largest Contentful Paint)Czas do namalowania największego bloku tekstu lub obrazu w widokuWpisy largest-contentful-paint, ostatni z nichDo 2,5 sPowyżej 4 sTak
CLS (Cumulative Layout Shift)Jak daleko widoczna treść przeskoczyła bez prośby użytkownikaWpisy layout-shift, w oknach sesjiDo 0,1Powyżej 0,25Tak
Czas ładowaniaDo końca obsługi zdarzenia loadWpis nawigacji, loadEventEndbrakbrakNie
INP (Interaction to Next Paint)Jak wolno strona odpowiada na kliknięcia, dotknięcia i klawiszeWymaga prawdziwych interakcjiDo 200 msPowyżej 500 msTak

Progi pochodzą z web.dev, dla LCP (otwiera się w nowej karcie), CLS (otwiera się w nowej karcie), TTFB (otwiera się w nowej karcie) i INP (otwiera się w nowej karcie), w brzmieniu z 21 października 2026, a web.dev stosuje je do 75. percentyla wizyt, osobno dla telefonów i komputerów. TTFB „nie jest wskaźnikiem Core Web Vitals”, jak pisze web.dev, ale od niego zaczyna się ładowanie. Czas ładowania nie ma żadnego progu: w speed-report.tsx jego Metric nie dostaje funkcji rating, więc jego podpis to napis „no threshold”, a nie wymyślony próg.

Jak demo mierzy samo siebie

Cały pomiar siedzi w jednym pliku bez Reacta, więc zadziała w każdym frameworku:

app/demos/measure-page-speed-in-browser/vitals.ts
// Page speed from the browser's own Performance APIs, without a library. // web.dev's thresholds: good up to the first value, poor above the second, needs improvement// in between. Times are in milliseconds, CLS is a score.export const thresholds = {  ttfb: [800, 1800],  lcp: [2500, 4000],  cls: [0.1, 0.25],} as const; export type Rating = "good" | "needs improvement" | "poor"; export function rate(metric: keyof typeof thresholds, value: number): Rating {  const [good, poor] = thresholds[metric];  if (value <= good) return "good";  return value <= poor ? "needs improvement" : "poor";} // TTFB and load time come from the page's navigation entry, in ms since the navigation// started. loadEventEnd stays 0 until the load event's handlers have finished, so read it in// a task queued after the load event.export function onNavigation(report: (ttfb: number, load: number) => void) {  const read = () => {    const [nav] = performance.getEntriesByType("navigation") as PerformanceNavigationTiming[];    if (nav) report(nav.responseStart, nav.loadEventEnd);  };  if (document.readyState === "complete") setTimeout(read);  else addEventListener("load", () => setTimeout(read), { once: true });} // LCP: the browser reports a new entry each time a larger element paints, and stops at the// first scroll, click or key press. The last entry is the page's LCP.export function observeLcp(report: (ms: number) => void) {  return observe("largest-contentful-paint", (entries) => {    const last = entries.at(-1);    if (last) report(last.startTime);  });} type LayoutShift = PerformanceEntry & { value: number; hadRecentInput: boolean }; // CLS: shifts less than 1 s apart form a session window of at most 5 s, and CLS is the// largest window's sum. A shift within 500 ms of a click or key press is expected: skip it.export function observeCls(report: (score: number) => void) {  let cls = 0;  let current = 0;  let first = 0;  let last = 0;  const stop = observe("layout-shift", (entries) => {    for (const shift of entries as LayoutShift[]) {      if (shift.hadRecentInput) continue;      if (current > 0 && shift.startTime - last < 1000 && shift.startTime - first < 5000) {        current += shift.value;      } else {        current = shift.value;        first = shift.startTime;      }      last = shift.startTime;      cls = Math.max(cls, current);    }    report(cls);  });  if (stop) report(cls); // no shift yet is a CLS of 0  return stop;} // Returns null where the browser doesn't support the entry type (layout-shift outside// Chromium), otherwise a function that stops observing.function observe(type: string, onEntries: (entries: PerformanceEntryList) => void) {  if (!PerformanceObserver.supportedEntryTypes.includes(type)) return null;  const observer = new PerformanceObserver((list) => onEntries(list.getEntries()));  // `buffered` also delivers the entries recorded before this call. It works only with  // `type` (one entry type per call), never with `entryTypes`.  observer.observe({ type, buffered: true });  return () => observer.disconnect();}
Z labu, commit a295bb0

TTFB i czas ładowania: wpis nawigacji

performance.getEntriesByType("navigation") zwraca jeden wpis PerformanceNavigationTiming (otwiera się w nowej karcie) dla bieżącego dokumentu, a jego startTime wynosi 0, więc każdy znacznik czasu w nim to milisekundy od startu nawigacji. Poradnik web.dev o TTFB definiuje TTFB jako czas od startTime do responseStart, więc responseStart to po prostu TTFB.

loadEventEnd to moment „tuż po zakończeniu obsługi zdarzenia load” (MDN), a standard HTML nadaje jego polu w danych o ładowaniu dokumentu (otwiera się w nowej karcie) wartość domyślną 0. Odczytane wewnątrz obsługi load daje 0. Dlatego onNavigation czeka na load, a potem kolejkuje jeszcze jedno zadanie przez setTimeout i dopiero wtedy czyta wpis.

LCP: liczy się ostatni wpis

W trakcie ładowania przeglądarka dodaje nowy wpis largest-contentful-paint za każdym razem, gdy namaluje większy element, a ostatni z nich to LCP strony, jak pokazuje przykład w MDN o interfejsie LargestContentfulPaint (otwiera się w nowej karcie). Liczy się nie tylko obraz, ale też tekst: web.dev mierzy blok tekstu najmniejszym prostokątem obejmującym jego węzły tekstowe. „Late hero” w demo to akapit dużego tekstu i to wystarcza, żeby stał się największym elementem.

Specyfikacja LCP (otwiera się w nowej karcie) kończy poszukiwania przy pierwszym przewinięciu albo interakcji. Przycisk nie przesunie więc LCP na stronie, na której sam się znajduje: pierwsze kliknięcie kończy pomiar. Dlatego Late hero to zwykły link do ?late, który ładuje nowy dokument, a blok czeka 3000 ms od startu nawigacji, a nie od kliknięcia.

CLS: okna sesji i niedawna interakcja

Każdy wpis layout-shift ma wynik: część widoku zajmowaną przez przesuwające się elementy pomnożoną przez to, jak daleko się przesunęły, jako część dłuższego boku widoku. web.dev grupuje przesunięcia w okna sesji (tak opisuje to poradnik web.dev o CLS): mniej niż 1 s między przesunięciami i najwyżej 5 s na okno, a CLS to największe okno. observeCls liczy to bieżące maksimum w kilku linijkach zamiast zwykłej sumy.

Przesunięcie w ciągu 500 ms od kliknięcia albo naciśnięcia klawisza jest oczekiwane i jego wpis ma flagę hadRecentInput interfejsu LayoutShift (otwiera się w nowej karcie), więc CLS może je pominąć. Dlatego baner czeka pełną sekundę po kliknięciu: gra rolę spóźnionej reklamy albo banera zgody na cookies, a nie menu, które użytkownik właśnie otworzył. Miejsce banera znaczy tyle samo co moment jego pojawienia się: nasze badanie banerów cookies nie wykazało na polskich stronach różnicy w CLS między stronami z narzędziem do zgody na cookies i bez niego, bo baner położony na stronie niczego nie przesuwa.

W Next.js: komponent kliencki, który mierzy w efekcie

API przeglądarki istnieją tylko w przeglądarce, a dokumentacja Next.js umieszcza API dostępne tylko w przeglądarce i logikę cyklu życia (otwiera się w nowej karcie) w komponentach klienckich. SpeedReport uruchamia obserwatory w useEffect, po hydratacji, i zatrzymuje je przy odmontowaniu:

app/demos/measure-page-speed-in-browser/speed-report.tsx
"use client"; import { useEffect, useState } from "react"; import styles from "./speed.module.css";import { observeCls, observeLcp, onNavigation, rate, type Rating } from "./vitals"; // undefined while measuring, "unsupported" where the browser has no API for it.type Value = number | "unsupported" | undefined; export function SpeedReport() {  const [ttfb, setTtfb] = useState<Value>();  const [load, setLoad] = useState<Value>();  const [lcp, setLcp] = useState<Value>();  const [cls, setCls] = useState<Value>();  const [banner, setBanner] = useState<"off" | "pending" | "on">("off");  const [lateHero, setLateHero] = useState(false);   // Browser APIs exist only in the browser: measure in an effect, after hydration.  useEffect(() => {    onNavigation((ttfb, load) => {      setTtfb(ttfb);      setLoad(load);    });    const stopLcp = observeLcp(setLcp);    const stopCls = observeCls(setCls);    if (!stopLcp) setLcp("unsupported");    if (!stopCls) setCls("unsupported");    return () => {      stopLcp?.();      stopCls?.();    };  }, []);   // ?late paints a big block 3 s after the navigation started, like a slow hero image.  useEffect(() => {    if (!new URLSearchParams(location.search).has("late")) return;    const timer = setTimeout(() => setLateHero(true), 3000 - performance.now());    return () => clearTimeout(timer);  }, []);   // The banner lands 1 s after the click: a shift within 500 ms of input would not count.  function insertBanner() {    setBanner("pending");    setTimeout(() => setBanner("on"), 1000);  }   return (    <>      {banner === "on" && (        <div className={styles.banner}>A banner that arrived 1 s after your click, with no space kept for it.</div>      )}       <div className={styles.metrics}>        <Metric name="LCP" title="Largest Contentful Paint" value={lcp} rating={(v) => rate("lcp", v)} />        <Metric name="CLS" title="Cumulative Layout Shift" value={cls} rating={(v) => rate("cls", v)} />        <Metric name="TTFB" title="Time to First Byte" value={ttfb} rating={(v) => rate("ttfb", v)} />        <Metric name="Load" title="Load time" value={load} />      </div>       <div className={styles.actions}>        <button type="button" onClick={insertBanner} disabled={banner !== "off"}>          Insert banner        </button>        <a href="?late">Late hero</a>        <a href="?">Reset</a>      </div>       {lateHero && <p className={styles.hero}>Late hero: painted 3 s after the page started loading.</p>}    </>  );} function Metric({  name,  title,  value,  rating,}: {  name: string;  title: string;  value: Value;  rating?: (value: number) => Rating;}) {  const measured = typeof value === "number";  const label = value === "unsupported" ? "not in this browser" : !measured ? "" : rating ? rating(value) : "no threshold";  return (    <section className={styles.metric} aria-label={title}>      <abbr className={styles.name} title={title}>        {name}      </abbr>      <data className={styles.value} value={measured ? value : ""}>        {measured ? format(name, value) : "–"}      </data>      <span className={styles.rating} data-rating={label}>        {label}      </span>    </section>  );} // CLS is a score; the rest are milliseconds, shown in seconds from 1 s up.function format(name: string, value: number) {  if (name === "CLS") return value.toFixed(3);  return value < 1000 ? `${Math.round(value)} ms` : `${(value / 1000).toFixed(2)} s`;}
Z labu, commit a295bb0

Strona wokół niego zostaje komponentem serwerowym. Umieszcza raport i krótką notkę o danych laboratoryjnych i tych z terenu:

app/demos/measure-page-speed-in-browser/page.tsx
import { SpeedReport } from "./speed-report";import styles from "./speed.module.css"; // The page is static: the numbers are measured in the browser by the client component.export default function Page() {  return (    <>      <SpeedReport />       <section className={styles.notes} aria-labelledby="lab-or-field">        <h2 id="lab-or-field">Lab or field</h2>        <p>          These are lab numbers: one load, in this browser, on this network, like Lighthouse or the DevTools          Performance panel. Core Web Vitals are judged on field data: real Chrome visits in the Chrome UX Report          (CrUX), shown in PageSpeed Insights, at the 75th percentile.        </p>        <p>          INP, the third Core Web Vital, needs a real interaction, so a page that has just loaded has none to report.          Load time is not a Core Web Vital and has no Google threshold.        </p>      </section>    </>  );}
Z labu, commit a295bb0

Jak zmierzyć szybkość strony we własnej aplikacji Next.js

  1. Skopiuj plik z pomiarem

    Weź vitals.ts bez zmian. Używa tylko performance i PerformanceObserver, więc działa w każdym kodzie przeglądarkowym, z Reactem albo bez.

  2. Uruchom go w komponencie klienckim

    Dodaj "use client" do pliku, który wywołuje onNavigation, observeLcp i observeCls, wywołaj je w useEffect i zachowaj funkcje rozłączające na sprzątanie efektu.

  3. Mierz build produkcyjny

    Uruchom next build i next start, otwórz stronę w nowej karcie i nie przewijaj ani nie klikaj, dopóki LCP się nie ustali: pierwsza interakcja go kończy.

  4. Zepsuj ją celowo

    Wstaw coś spóźnionego nad linią przewijania albo opóźnij największy obraz i patrz, jak rusza się CLS albo LCP. Jeśli nic się nie rusza, sprawdź PerformanceObserver.supportedEntryTypes w tej przeglądarce.

  5. Porównaj z danymi z terenu

    Wpisz ten sam adres w PageSpeed Insights. Dane z terenu to Chrome UX Report od prawdziwych odwiedzających; uruchomienie Lighthouse to kolejne ładowanie laboratoryjne, tyle że na urządzeniu i sieci Google, a nie Twoich.

Dane laboratoryjne a dane z terenu: skąd różnica z PageSpeed Insights

Demo pokazuje dane laboratoryjne: jedno ładowanie, w jednej przeglądarce, w jednej sieci. PageSpeed Insights (otwiera się w nowej karcie) pokazuje oba rodzaje. Dane z terenu pochodzą z Chrome User Experience Report (CrUX) z ostatnich 28 dni, podawane na 75. percentylu; dane laboratoryjne to Lighthouse ładujący stronę „na jednym urządzeniu i przy stałych warunkach sieci”, a Google sam pisze, że wartości mogą się różnić. Nowa strona albo strona z małym ruchem z Chrome może nie mieć danych z terenu wcale; PageSpeed Insights pokazuje wtedy dane całej witryny.

Wyniki rozjeżdżają się z prostych powodów: Twój laptop i łącze są szybsze niż telefon odwiedzającego z 75. percentyla, ładowanie laboratoryjne nigdy nie przewija ani nie klika, a ten sam poradnik CLS ostrzega, że narzędzia laboratoryjne mogą pokazać niższy CLS niż ten, który dostają prawdziwi użytkownicy. Skrajny przypadek to INP: narzędzie, które tylko ładuje stronę, nie ma czego zmierzyć, więc Lighthouse pokazuje zamiast niego Total Blocking Time, który poradnik INP na web.dev nazywa rozsądnym zastępnikiem, ale nie zamiennikiem.

Żeby zobaczyć, jak to wygląda w terenie, nasze badanie polskich stron za CDN pokazuje, jaki TTFB jest dobry w praktyce, a badanie sklepów pokazuje, gdzie sklepy tracą: na ładowaniu, reakcji czy układzie.

Co psuje pomiar

  • Za wczesny odczyt loadEventEnd. W obsłudze load albo przed tym zdarzeniem wciąż wynosi 0. Zakolejkuj zadanie po load, tak jak robi to onNavigation.
  • entryTypes razem z buffered. MDN pisze, że buffered wolno używać tylko z type (otwiera się w nowej karcie), czyli jeden typ wpisów na jedno wywołanie observe(). Bez buffered obserwator uruchomiony po hydratacji przegapi LCP i przesunięcia, które były przed nim.
  • Przeglądarki bez tego API. observe() w vitals.ts najpierw sprawdza PerformanceObserver.supportedEntryTypes i zwraca null; SpeedReport zapisuje wtedy "unsupported", a Metric zamienia to na podpis „not in this browser” zamiast fałszywego zera.
  • Nawigacja po stronie klienta. Oś czasu ma jeden wpis nawigacji, dla bieżącego dokumentu. Zmiana trasy przez next/link to przejście po stronie klienta (otwiera się w nowej karcie), które aktualizuje stronę zamiast ją przeładować, więc nowy dokument się nie ładuje: wpis nawigacji, z jego TTFB i czasem ładowania, wciąż należy do pierwszego, a LCP zatrzymał się na kliknięciu, które zaczęło przejście. Dlatego Late hero i Reset w demo to zwykłe linki.
  • Obrazy z innej domeny. Bez nagłówka Timing-Allow-Origin obraz z innego originu dostaje zgrubny czas renderowania (MDN), a web.dev nadal zaleca wysyłanie tego nagłówka.
  • Ukryte karty i pamięć podręczna wstecz/dalej. Według artykułu o LCP na web.dev narzędzia Google nie podają LCP dla strony załadowanej w karcie w tle, CLS trzeba zgłaszać za każdym razem, gdy strona przechodzi w tło, a strona przywrócona z pamięci podręcznej wstecz/dalej liczy się jako nowa wizyta. Demo tylko pokazuje liczby; żeby wysyłać je od prawdziwych odwiedzających, użyj w Next.js hooka useReportWebVitals (otwiera się w nowej karcie), który zgłasza TTFB, FCP, LCP, CLS i INP z oceną (rating) i typem nawigacji.
Jak sprawdzić szybkość strony w JavaScripcie?

Odczytaj wpis nawigacji strony przez performance.getEntriesByType('navigation'): responseStart to czas do pierwszego bajtu, a loadEventEnd to czas ładowania, gdy zdarzenie load już się skończyło. Dla LCP i CLS utwórz PerformanceObserver i wywołaj observe({ type: 'largest-contentful-paint', buffered: true }) oraz observe({ type: 'layout-shift', buffered: true }). Ostatni wpis LCP to LCP strony; CLS to największa seria przesunięć układu, które nie nastąpiły po kliknięciu ani naciśnięciu klawisza.

Jaki czas ładowania strony jest dobry?

Google nie podaje progu dla całkowitego czasu ładowania. Wskaźnik ładowania, który ocenia, to Largest Contentful Paint: dobry do 2,5 sekundy, słaby powyżej 4 sekund, na 75. percentylu wizyt. Czas do pierwszego bajtu (TTFB), od którego ładowanie się zaczyna, jest dobry do 0,8 sekundy.

Czym się różnią dane laboratoryjne od danych z terenu?

Dane laboratoryjne to jedno ładowanie w kontrolowanych warunkach: w Twojej przeglądarce, w Lighthouse albo w panelu Performance w DevTools. Dane z terenu pochodzą od prawdziwych odwiedzających: PageSpeed Insights pokazuje Chrome UX Report z ostatnich 28 dni na 75. percentylu. Core Web Vitals ocenia się na danych z terenu, więc szybkie ładowanie na Twoim laptopie nie oznacza zaliczonego wyniku.

Czy potrzebuję biblioteki web-vitals, czy wystarczy API przeglądarki?

Żeby zobaczyć liczby, wystarczy API przeglądarki: wpis nawigacji i dwa wywołania PerformanceObserver. Żeby wysyłać końcowe wartości od prawdziwych odwiedzających, czyli obsłużyć ukryte karty, strony przywrócone z pamięci podręcznej wstecz/dalej i INP, użyj biblioteki web-vitals, a przy Next.js hooka useReportWebVitals z next/web-vitals.

Jak zmierzyć INP (Interaction to Next Paint)?

INP wymaga prawdziwych interakcji, więc strona, która się tylko załadowała, nie ma czego zgłosić, a Lighthouse pokazuje zamiast niego Total Blocking Time. Mierz INP u prawdziwych odwiedzających: w Next.js useReportWebVitals zgłasza INP razem z pozostałymi wskaźnikami. Dobry INP to 200 milisekund lub mniej, słaby to powyżej 500.

Dalsza lektura

Web Vitals (web.dev) (otwiera się w nowej karcie)Trzy wskaźniki Core Web Vitals, ich progi i zasada 75. percentyla.web.dev
Largest Contentful Paint (web.dev) (otwiera się w nowej karcie)Które elementy się liczą, jak mierzy się ich rozmiar i kiedy pomiar się kończy.web.dev
Cumulative Layout Shift (web.dev) (otwiera się w nowej karcie)Wynik przesunięcia układu, okna sesji i zasada 500 ms po interakcji.web.dev
Time to First Byte (web.dev) (otwiera się w nowej karcie)Co obejmuje TTFB i jego progi 0,8 s i 1,8 s.web.dev
PerformanceObserver.observe() (MDN) (otwiera się w nowej karcie)Opcje type, entryTypes i buffered oraz to, które z nich idą w parze.developer.mozilla.org
PerformanceNavigationTiming (MDN) (otwiera się w nowej karcie)Znaczniki czasu wpisu nawigacji, w tym loadEventEnd.developer.mozilla.org
useReportWebVitals (dokumentacja Next.js) (otwiera się w nowej karcie)Zgłaszanie Web Vitals od prawdziwych odwiedzających w aplikacji Next.js.nextjs.org
O PageSpeed Insights (otwiera się w nowej karcie)Dane laboratoryjne z Lighthouse, dane z terenu z CrUX i skąd różnice.developers.google.com
WydajnośćCore Web Vitals sklepów: która platforma jest najszybsza?Core Web Vitals sklepów w danych CrUX: na telefonach zalicza je ponad połowa. Prowadzą Shopify i PrestaShop, Shoper odstaje przez INP. Która platforma wygrywa?
WydajnośćCDN na polskich stronach: Cloudflare, cache i TTFBCloudflare obsługuje co trzecią z 7785 stron .pl, ale większość przekazuje HTML do serwera, a TTFB na telefonach jest jak bez CDN. Szybsze są strony z cache.

Napisz, co budujemy.

Odpowiadamy w ciągu jednego dnia roboczego.