---
title: "How to measure page speed in the browser: TTFB, LCP, CLS"
description: "Measure page speed in code: read TTFB, LCP, CLS and load time from the browser's Performance APIs in Next.js, with no library, in a live demo you can break."
canonical_url: "https://www.outofplace.space/blog/measure-page-speed-in-browser"
language: "en"
last_updated: "2026-10-21"
alternates:
  pl: "https://www.outofplace.space/pl/blog/jak-sprawdzic-szybkosc-strony.md"
---

# How to measure page speed in the browser: TTFB, LCP, CLS

> Measure page speed in code: read TTFB, LCP, CLS and load time from the browser's Performance APIs in Next.js, with no library, in a live demo you can break.

Published 2026-10-21 · Category: Web development · Tags: Core Web Vitals, Next.js

To measure page speed in the browser, read four numbers from its own Performance APIs: TTFB and load time from the page's navigation entry (`responseStart`, and `loadEventEnd` once the load event is over), LCP and CLS from a `PerformanceObserver` called with `type` and `buffered: true`. Judge them against web.dev's thresholds: LCP is good up to 2.5 s, CLS up to 0.1, TTFB up to 0.8 s. In a Next.js app that is one small file and a Client Component, with no library. These are lab numbers from one load; for what real visitors get on Polish sites, our study of [Core Web Vitals by ecommerce platform](https://www.outofplace.space/blog/core-web-vitals-polish-ecommerce) has the field data.

Live demo: https://lab.outofplace.space/demos/measure-page-speed-in-browser (Next.js 16.4.0; source at commit a295bb0: https://github.com/outof-place/outofplace-lab/tree/a295bb0a726757641128a8ad11b661e453ae7014/app/demos/measure-page-speed-in-browser)

The demo measures itself, and its two controls stage the two classic failures in code. **Insert banner** calls `insertBanner`, which sets a 1,000 ms timer and then renders a banner above the numbers with no space kept for it: a late shift for CLS. **Late hero** is a link to `?late`, where the component renders a large paragraph 3,000 ms after the navigation started: a late paint for LCP.

## Which numbers mean page speed

"Page speed" is several numbers, and only some of them are [Core Web Vitals](https://web.dev/articles/vitals), the three metrics Google uses to describe loading, responsiveness and visual stability. The demo shows the four that one page load can give you; INP needs an interaction.

| Metric | What it measures | Where the browser keeps it | Good | Poor | Core Web Vital |
| --- | --- | --- | --- | --- | --- |
| TTFB (Time to First Byte) | Until the first byte of the HTML arrives | Navigation entry, `responseStart` | Up to 0.8 s | Over 1.8 s | No |
| LCP (Largest Contentful Paint) | Until the largest text block or image in view paints | `largest-contentful-paint` entries, the last one | Up to 2.5 s | Over 4 s | Yes |
| CLS (Cumulative Layout Shift) | How far visible content jumped without the user asking | `layout-shift` entries, in session windows | Up to 0.1 | Over 0.25 | Yes |
| Load time | Until the `load` event's handlers finish | Navigation entry, `loadEventEnd` | none | none | No |
| INP (Interaction to Next Paint) | How slowly the page answers clicks, taps and keys | Needs real interactions | Up to 200 ms | Over 500 ms | Yes |

The thresholds are web.dev's, for [LCP](https://web.dev/articles/lcp), [CLS](https://web.dev/articles/cls), [TTFB](https://web.dev/articles/ttfb) and [INP](https://web.dev/articles/inp), read on 21 October 2026, and web.dev applies them to the 75th percentile of visits, split by phone and desktop. TTFB "isn't a Core Web Vitals metric", in web.dev's words, but it is where loading starts, and load time has no threshold at all: in `speed-report.tsx` the load time's `Metric` gets no `rating` function, so its label is the string "no threshold" rather than an invented one.

## How the demo measures itself

All the measuring sits in one file with no React in it, so it works in any framework:

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 and load time: the navigation entry

`performance.getEntriesByType("navigation")` returns one [`PerformanceNavigationTiming`](https://developer.mozilla.org/en-US/docs/Web/API/PerformanceNavigationTiming) entry for the current document, and its `startTime` is 0, so every timestamp on it is milliseconds since the navigation started. web.dev's TTFB guide defines TTFB as the time between `startTime` and `responseStart`, so `responseStart` is the TTFB itself.

`loadEventEnd` is the time "immediately after the current document's `load` event handler completes" (MDN), and the HTML standard gives its [document load timing](https://html.spec.whatwg.org/multipage/dom.html#document-load-timing-info) a default of 0. Read it inside a `load` handler and you get 0. That is why `onNavigation` waits for `load` and then queues one more task with `setTimeout` before it reads the entry.

### LCP: the last entry wins

While the page loads, the browser queues a new `largest-contentful-paint` entry each time a larger element paints, and the last one is the page's LCP, as [MDN's example](https://developer.mozilla.org/en-US/docs/Web/API/LargestContentfulPaint) shows. Text counts as well as images: web.dev's LCP guide measures a text block by the smallest rectangle around its text nodes. The demo's late hero is a paragraph of large text, and that is enough to become the largest element.

The [LCP specification](https://w3c.github.io/largest-contentful-paint/) stops the search at the first scroll or input. So a button cannot move LCP on the page it sits on: the first click ends the measurement. That is why **Late hero** is a plain link to `?late`, which loads a new document, and why the block waits for 3,000 ms after the navigation started rather than after the click.

### CLS: session windows and recent input

Each `layout-shift` entry has a score: the share of the viewport the moving elements cover, times how far they moved as a share of the viewport's larger side. web.dev's CLS guide groups shifts into session windows, with less than 1 s between shifts and 5 s at most per window, and CLS is the largest window. `observeCls` keeps that running maximum in a few lines instead of a plain sum.

A shift within 500 ms of a click or key press is expected, and its entry carries [`hadRecentInput`](https://developer.mozilla.org/en-US/docs/Web/API/LayoutShift) so CLS can skip it. That is why the banner waits a full second after the click: it plays a late ad or consent banner, not a menu the user just opened. Where a banner sits matters as much as when it arrives: our survey of Polish sites found [no difference in CLS](https://www.outofplace.space/blog/cookie-consent-polish-web#layout-shift-no-difference) between sites with a consent tool and without one, because a banner laid over the page shifts nothing.

### In Next.js: a Client Component that measures in an effect

The browser APIs exist only in the browser, and the Next.js docs put [browser-only APIs and lifecycle logic](https://nextjs.org/docs/app/getting-started/server-and-client-components) in Client Components. `SpeedReport` starts the observers in `useEffect`, after hydration, and stops them when it unmounts:

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

The page around it stays a Server Component. It places the report and a short note on lab against field data:

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

## How to measure page speed in your own Next.js app

**1. Copy the measuring file.**
Take `vitals.ts` as it is. It uses only `performance` and `PerformanceObserver`, so it runs in any browser code, with or without React.

**2. Start it in a Client Component.**
Add `"use client"` to the file that calls `onNavigation`, `observeLcp` and `observeCls`, call them in `useEffect`, and keep the disconnect functions for the effect's cleanup.

**3. Measure a production build.**
Run `next build` and `next start`, open the page in a fresh tab and do not scroll or click until LCP is in: the first input ends it.

**4. Break it on purpose.**
Insert something late above the fold, or delay your largest image, and watch CLS or LCP move. If nothing moves, check `PerformanceObserver.supportedEntryTypes` in that browser.

**5. Compare with field data.**
Put the same URL into PageSpeed Insights. Its field data is the Chrome UX Report for real visitors; its Lighthouse run is another lab load, on Google's device and network instead of yours.

## Lab data vs field data: why your numbers differ from PageSpeed Insights

What the demo shows is lab data: one load, in one browser, on one network. [PageSpeed Insights](https://developers.google.com/speed/docs/insights/v5/about) shows both kinds. Its field data comes from the Chrome User Experience Report (CrUX) over the previous 28 days, reported at the 75th percentile; its lab data is Lighthouse loading the page "on a single device and fixed set of network conditions", and Google's own page says "the values may differ". A new page, or one with little Chrome traffic, may have no field data at all, and PageSpeed Insights then falls back to the whole site's.

The two disagree for plain reasons: your laptop and connection are faster than the 75th-percentile visitor's phone, a lab load never scrolls or clicks, and the same CLS guide warns that lab tools can report a lower CLS than real users get. INP is the extreme case: a lab tool that only loads the page has no interaction to time, so Lighthouse shows Total Blocking Time instead, which web.dev's INP guide calls "a reasonable proxy" but no substitute.

For a sense of what the field looks like here, our survey of Polish sites behind a CDN shows [what a good TTFB is](https://www.outofplace.space/blog/cdn-polish-web#what-is-a-good-ttfb) in practice, and the ecommerce study shows where shops lose: loading, responsiveness or layout.

## What breaks the measurement

- **Reading `loadEventEnd` too early.** Inside a `load` handler, or before the event, it is still 0. Queue a task after `load`, as `onNavigation` does.
- **`entryTypes` with `buffered`.** MDN says `buffered` [must be used only with `type`](https://developer.mozilla.org/en-US/docs/Web/API/PerformanceObserver/observe), one entry type per `observe()` call. Without `buffered`, an observer started after hydration misses the LCP and shifts that came before it.
- **Browsers without the API.** `observe()` in `vitals.ts` checks `PerformanceObserver.supportedEntryTypes` first and returns `null`; `SpeedReport` then stores `"unsupported"`, and `Metric` turns that into the label "not in this browser" instead of a fake 0.
- **Client-side navigations.** The timeline holds one navigation entry, for the current document. A route change through `next/link` is a [client-side transition](https://nextjs.org/docs/app/getting-started/linking-and-navigating) that updates the page instead of reloading it, so no new document loads: the navigation entry, with its TTFB and load time, still belongs to the first one, and LCP stopped at the click that started the transition. The demo's **Late hero** and **Reset** are plain links for that reason.
- **Cross-origin images.** Without a `Timing-Allow-Origin` header, an image from another origin gets a coarsened render time (MDN), and web.dev still recommends sending the header.
- **Hidden tabs and the back/forward cache.** Google's tools don't report LCP for a page loaded in a background tab, says web.dev's LCP guide; CLS should be reported every time the page goes to the background, and a page restored from the back/forward cache counts as a new visit. The demo only shows numbers; to send them from real visitors, use Next.js's [`useReportWebVitals`](https://nextjs.org/docs/app/api-reference/functions/use-report-web-vitals), which reports TTFB, FCP, LCP, CLS and INP with a `rating` and the navigation type.

## Further reading

- [Web Vitals (web.dev)](https://web.dev/articles/vitals): The three Core Web Vitals, their thresholds and the 75th percentile rule.
- [Largest Contentful Paint (web.dev)](https://web.dev/articles/lcp): Which elements count, how their size is measured and when reporting stops.
- [Cumulative Layout Shift (web.dev)](https://web.dev/articles/cls): Layout shift scores, session windows and the 500 ms input rule.
- [Time to First Byte (web.dev)](https://web.dev/articles/ttfb): What TTFB covers and its 0.8 s and 1.8 s thresholds.
- [PerformanceObserver.observe() (MDN)](https://developer.mozilla.org/en-US/docs/Web/API/PerformanceObserver/observe): The type, entryTypes and buffered options, and which go together.
- [PerformanceNavigationTiming (MDN)](https://developer.mozilla.org/en-US/docs/Web/API/PerformanceNavigationTiming): The navigation entry's timestamps, loadEventEnd among them.
- [useReportWebVitals (Next.js docs)](https://nextjs.org/docs/app/api-reference/functions/use-report-web-vitals): Reporting Web Vitals from real visitors in a Next.js app.
- [About PageSpeed Insights](https://developers.google.com/speed/docs/insights/v5/about): Lab data from Lighthouse, field data from CrUX, and why they differ.
- [Core Web Vitals by ecommerce platform: who is fastest?](https://www.outofplace.space/blog/core-web-vitals-polish-ecommerce): CrUX field data for Polish online shops: about half pass Core Web Vitals on phones. Shopify and PrestaShop lead, Shoper lags on INP. Which platform is fastest?
- [CDN market share in Poland, and what it does to TTFB](https://www.outofplace.space/blog/cdn-polish-web): Cloudflare answers a third of 7,785 .pl homepages, yet most pass HTML to the origin, and their phone TTFB matches sites without a CDN. Cached ones are faster.
