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.

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.
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.
| 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 (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:
// 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 (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:
"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:
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
Copy the measuring file
Take
vitals.tsas it is. It uses onlyperformanceandPerformanceObserver, so it runs in any browser code, with or without React.Start it in a Client Component
Add
"use client"to the file that callsonNavigation,observeLcpandobserveCls, call them inuseEffect, and keep the disconnect functions for the effect's cleanup.Measure a production build
Run
next buildandnext start, open the page in a fresh tab and do not scroll or click until LCP is in: the first input ends it.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.supportedEntryTypesin that browser.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
loadEventEndtoo early. Inside aloadhandler, or before the event, it is still 0. Queue a task afterload, asonNavigationdoes. entryTypeswithbuffered. MDN saysbufferedmust be used only withtype(opens in a new tab), one entry type perobserve()call. Withoutbuffered, an observer started after hydration misses the LCP and shifts that came before it.- Browsers without the API.
observe()invitals.tschecksPerformanceObserver.supportedEntryTypesfirst and returnsnull;SpeedReportthen stores"unsupported", andMetricturns 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/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.link - Cross-origin images. Without a
Timing-Allow-Originheader, 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 aratingand 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.
