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.

Reading time: 7 min readPublished:

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 has the field data.

Measure page speed · Next.js 16.4.0Open the demoView source

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 (opens in a new tab), 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.

MetricWhat it measuresWhere the browser keeps itGoodPoorCore Web Vital
TTFB (Time to First Byte)Until the first byte of the HTML arrivesNavigation entry, responseStartUp to 0.8 sOver 1.8 sNo
LCP (Largest Contentful Paint)Until the largest text block or image in view paintslargest-contentful-paint entries, the last oneUp to 2.5 sOver 4 sYes
CLS (Cumulative Layout Shift)How far visible content jumped without the user askinglayout-shift entries, in session windowsUp to 0.1Over 0.25Yes
Load timeUntil the load event's handlers finishNavigation entry, loadEventEndnonenoneNo
INP (Interaction to Next Paint)How slowly the page answers clicks, taps and keysNeeds real interactionsUp to 200 msOver 500 msYes

The thresholds are web.dev's, for LCP (opens in a new tab), CLS (opens in a new tab), TTFB (opens in a new tab) and INP (opens in a new tab), 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
// 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();}
From the lab at commit a295bb0

TTFB and load time: the navigation entry

performance.getEntriesByType("navigation") returns one PerformanceNavigationTiming (opens in a new tab) 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 (opens in a new tab) 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 (opens in a new tab) 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 (opens in a new tab) 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 (opens in a new tab) 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 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 (opens in a new tab) 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
"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`;}
From the lab at commit a295bb0

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
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>    </>  );}
From the lab at commit a295bb0

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 (opens in a new tab) 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 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 (opens in a new tab), 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 (opens in a new tab) 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 (opens in a new tab), which reports TTFB, FCP, LCP, CLS and INP with a rating and the navigation type.
How do I check website speed in JavaScript?

Read the page's navigation entry with performance.getEntriesByType('navigation'): responseStart is the time to first byte and loadEventEnd the load time, once the load event is over. For LCP and CLS, create a PerformanceObserver and call observe({ type: 'largest-contentful-paint', buffered: true }) and observe({ type: 'layout-shift', buffered: true }). The last LCP entry is the page's LCP; CLS is the largest burst of layout shifts that did not follow a click or key press.

What is a good page load time?

Google sets no threshold for total load time. The loading metric it judges is Largest Contentful Paint: good at 2.5 seconds or less, poor above 4 seconds, at the 75th percentile of visits. Time to first byte, where loading starts, is good at 0.8 seconds or less.

What is the difference between lab data and field data?

Lab data is one load in a controlled setting: your browser, Lighthouse or the DevTools Performance panel. Field data comes from real visitors: PageSpeed Insights shows the Chrome UX Report's last 28 days at the 75th percentile. Core Web Vitals are judged on field data, so a fast load on your laptop does not mean a passing score.

Do I need the web-vitals library or can I use the browser API alone?

To see the numbers, the browser API is enough: a navigation entry and two PerformanceObserver calls. To report final values from real visitors, which means handling hidden tabs, pages restored from the back/forward cache and INP, use the web-vitals library or, in Next.js, the useReportWebVitals hook from next/web-vitals.

How do I measure INP?

INP needs real interactions, so a page that has only loaded has none to report, and Lighthouse shows Total Blocking Time as a stand-in. Measure it from real visitors: in Next.js, useReportWebVitals reports INP with the other metrics. Good is 200 milliseconds or less, poor above 500.

Further reading

Web Vitals (web.dev) (opens in a new tab)The three Core Web Vitals, their thresholds and the 75th percentile rule.web.dev
Largest Contentful Paint (web.dev) (opens in a new tab)Which elements count, how their size is measured and when reporting stops.web.dev
Cumulative Layout Shift (web.dev) (opens in a new tab)Layout shift scores, session windows and the 500 ms input rule.web.dev
Time to First Byte (web.dev) (opens in a new tab)What TTFB covers and its 0.8 s and 1.8 s thresholds.web.dev
PerformanceObserver.observe() (MDN) (opens in a new tab)The type, entryTypes and buffered options, and which go together.developer.mozilla.org
PerformanceNavigationTiming (MDN) (opens in a new tab)The navigation entry's timestamps, loadEventEnd among them.developer.mozilla.org
useReportWebVitals (Next.js docs) (opens in a new tab)Reporting Web Vitals from real visitors in a Next.js app.nextjs.org
About PageSpeed Insights (opens in a new tab)Lab data from Lighthouse, field data from CrUX, and why they differ.developers.google.com
PerformanceCore Web Vitals by ecommerce platform: who is fastest?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?
PerformanceCDN market share in Poland, and what it does to TTFBCloudflare 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.

Tell us what we’re building.

We reply within one business day.