---
title: "Jak sprawdzić szybkość strony w przeglądarce: TTFB, LCP, CLS"
description: "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ć."
canonical_url: "https://www.outofplace.space/pl/blog/jak-sprawdzic-szybkosc-strony"
language: "pl"
last_updated: "2026-10-21"
alternates:
  en: "https://www.outofplace.space/blog/measure-page-speed-in-browser.md"
---

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

Opublikowano 2026-10-21 · Kategoria: Web development · Tagi: Core Web Vitals, Next.js

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](https://www.outofplace.space/pl/blog/core-web-vitals-sklepow-internetowych).

Demo na żywo: https://lab.outofplace.space/demos/measure-page-speed-in-browser (Next.js 16.4.0; kod z commita a295bb0: https://github.com/outof-place/outofplace-lab/tree/a295bb0a726757641128a8ad11b661e453ae7014/app/demos/measure-page-speed-in-browser)

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](https://web.dev/articles/vitals), 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](https://web.dev/articles/lcp), [CLS](https://web.dev/articles/cls), [TTFB](https://web.dev/articles/ttfb) i [INP](https://web.dev/articles/inp), 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 (https://github.com/outof-place/outofplace-lab/blob/a295bb0a726757641128a8ad11b661e453ae7014/app/demos/measure-page-speed-in-browser/vitals.ts)

```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();
}
```

### TTFB i czas ładowania: wpis nawigacji

`performance.getEntriesByType("navigation")` zwraca jeden wpis [`PerformanceNavigationTiming`](https://developer.mozilla.org/en-US/docs/Web/API/PerformanceNavigationTiming) 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](https://html.spec.whatwg.org/multipage/dom.html#document-load-timing-info) 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](https://developer.mozilla.org/en-US/docs/Web/API/LargestContentfulPaint). 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](https://w3c.github.io/largest-contentful-paint/) 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](https://developer.mozilla.org/en-US/docs/Web/API/LayoutShift), 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](https://www.outofplace.space/pl/blog/baner-cookies-na-polskich-stronach#przesuniecia-ukladu-bez-roznicy) 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](https://nextjs.org/docs/app/getting-started/server-and-client-components) 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 (https://github.com/outof-place/outofplace-lab/blob/a295bb0a726757641128a8ad11b661e453ae7014/app/demos/measure-page-speed-in-browser/speed-report.tsx)

```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`;
}
```

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 (https://github.com/outof-place/outofplace-lab/blob/a295bb0a726757641128a8ad11b661e453ae7014/app/demos/measure-page-speed-in-browser/page.tsx)

```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>
    </>
  );
}
```

## 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](https://developers.google.com/speed/docs/insights/v5/about) 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](https://www.outofplace.space/pl/blog/cdn-na-polskich-stronach#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`](https://developer.mozilla.org/en-US/docs/Web/API/PerformanceObserver/observe), 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](https://nextjs.org/docs/app/getting-started/linking-and-navigating), 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`](https://nextjs.org/docs/app/api-reference/functions/use-report-web-vitals), który zgłasza TTFB, FCP, LCP, CLS i INP z oceną (`rating`) i typem nawigacji.

## Dalsza lektura

- [Web Vitals (web.dev)](https://web.dev/articles/vitals): Trzy wskaźniki Core Web Vitals, ich progi i zasada 75. percentyla.
- [Largest Contentful Paint (web.dev)](https://web.dev/articles/lcp): Które elementy się liczą, jak mierzy się ich rozmiar i kiedy pomiar się kończy.
- [Cumulative Layout Shift (web.dev)](https://web.dev/articles/cls): Wynik przesunięcia układu, okna sesji i zasada 500 ms po interakcji.
- [Time to First Byte (web.dev)](https://web.dev/articles/ttfb): Co obejmuje TTFB i jego progi 0,8 s i 1,8 s.
- [PerformanceObserver.observe() (MDN)](https://developer.mozilla.org/en-US/docs/Web/API/PerformanceObserver/observe): Opcje type, entryTypes i buffered oraz to, które z nich idą w parze.
- [PerformanceNavigationTiming (MDN)](https://developer.mozilla.org/en-US/docs/Web/API/PerformanceNavigationTiming): Znaczniki czasu wpisu nawigacji, w tym loadEventEnd.
- [useReportWebVitals (dokumentacja Next.js)](https://nextjs.org/docs/app/api-reference/functions/use-report-web-vitals): Zgłaszanie Web Vitals od prawdziwych odwiedzających w aplikacji Next.js.
- [O PageSpeed Insights](https://developers.google.com/speed/docs/insights/v5/about): Dane laboratoryjne z Lighthouse, dane z terenu z CrUX i skąd różnice.
- [Core Web Vitals sklepów: która platforma jest najszybsza?](https://www.outofplace.space/pl/blog/core-web-vitals-sklepow-internetowych): 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?
- [CDN na polskich stronach: Cloudflare, cache i TTFB](https://www.outofplace.space/pl/blog/cdn-na-polskich-stronach): Cloudflare 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.
