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ć.

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.
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źnik | Co mierzy | Gdzie przeglądarka go trzyma | Dobry | Słaby | Core Web Vital |
|---|---|---|---|---|---|
| TTFB (Time to First Byte) | Czas do pierwszego bajtu HTML | Wpis nawigacji, responseStart | Do 0,8 s | Powyżej 1,8 s | Nie |
| LCP (Largest Contentful Paint) | Czas do namalowania największego bloku tekstu lub obrazu w widoku | Wpisy largest-contentful-paint, ostatni z nich | Do 2,5 s | Powyżej 4 s | Tak |
| CLS (Cumulative Layout Shift) | Jak daleko widoczna treść przeskoczyła bez prośby użytkownika | Wpisy layout-shift, w oknach sesji | Do 0,1 | Powyżej 0,25 | Tak |
| Czas ładowania | Do końca obsługi zdarzenia load | Wpis nawigacji, loadEventEnd | brak | brak | Nie |
| INP (Interaction to Next Paint) | Jak wolno strona odpowiada na kliknięcia, dotknięcia i klawisze | Wymaga prawdziwych interakcji | Do 200 ms | Powyżej 500 ms | Tak |
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:
// 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();}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:
"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`;}Strona wokół niego zostaje komponentem serwerowym. Umieszcza raport i krótką notkę o danych laboratoryjnych i tych z terenu:
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> </> );}Jak zmierzyć szybkość strony we własnej aplikacji Next.js
Skopiuj plik z pomiarem
Weź
vitals.tsbez zmian. Używa tylkoperformanceiPerformanceObserver, więc działa w każdym kodzie przeglądarkowym, z Reactem albo bez.Uruchom go w komponencie klienckim
Dodaj
"use client"do pliku, który wywołujeonNavigation,observeLcpiobserveCls, wywołaj je wuseEffecti zachowaj funkcje rozłączające na sprzątanie efektu.Mierz build produkcyjny
Uruchom
next buildinext start, otwórz stronę w nowej karcie i nie przewijaj ani nie klikaj, dopóki LCP się nie ustali: pierwsza interakcja go kończy.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.supportedEntryTypesw tej przeglądarce.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łudzeloadalbo przed tym zdarzeniem wciąż wynosi 0. Zakolejkuj zadanie poload, tak jak robi toonNavigation. entryTypesrazem zbuffered. MDN pisze, żebufferedwolno używać tylko ztype(otwiera się w nowej karcie), czyli jeden typ wpisów na jedno wywołanieobserve(). Bezbufferedobserwator uruchomiony po hydratacji przegapi LCP i przesunięcia, które były przed nim.- Przeglądarki bez tego API.
observe()wvitals.tsnajpierw sprawdzaPerformanceObserver.supportedEntryTypesi zwracanull;SpeedReportzapisuje wtedy"unsupported", aMetriczamienia 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/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.link - Obrazy z innej domeny. Bez nagłówka
Timing-Allow-Originobraz 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.
