cf.completefrontend
Open lab My progress
THE NEXT.JS FIELD GUIDE

Core Web Vitals in Next.js

Measure LCP, CLS and INP with useReportWebVitals, read lab and field data honestly, and fix a late hero photo and a page that jumps in a Next.js app.

By the end, you can
  • 01
    Read the three numbersSay what LCP, CLS and INP measure, their good thresholds, and why they're judged at the 75th percentile of real visits.
  • 02
    Measure them in your appReport every metric with useReportWebVitals from a small Client Component, and send it to an endpoint of your own.
  • 03
    Fix the usual causesPreload the photo that is the LCP, give streamed content a fallback of its size, and let a click paint before slow work.

The tea shop, in three numbers

Three things decide how the tea shop feels to a visitor: how soon the main content shows, whether the page stays still while it loads, and how fast it answers a click. Google gives each one a number, and together they're called the Core Web Vitals: LCP for loading, CLS for staying still, and INP for answering. Google's tools report them, and Google Search uses them, among many other signals.

They're measured in the visitor's browser, while the page is open. Next.js gives you one hook to read them, useReportWebVitals. Here the tea shop shows every number the hook reports, in a list in the corner of each page. This is a real Next.js 16.4.3 project, built with next build, run with next start, and visited in real Chrome:

The tea shop, with a list of the metrics it measuresNext.js
app/cart/
app/web-vitals.tsx
"use client"; import { useCallback, useState } from "react";import { useReportWebVitals } from "next/web-vitals"; type Metric = Parameters<Parameters<typeof useReportWebVitals>[0]>[0]; export function WebVitals() {  const [metrics, setMetrics] = useState<Metric[]>([]);  // The same function on every render, so the hook reports each metric once.  const report = useCallback((metric: Metric) => {    setMetrics((list) => [...list, metric]);  }, []);  useReportWebVitals(report);   return (    <ul aria-label="Web Vitals" style={{ position: "fixed", top: 0, right: 0 }}>      {metrics.map((metric) => (        <li key={metric.id}>          {metric.name} on {new URL(metric.navigationURL).pathname}: {show(metric)} ({metric.rating})        </li>      ))}    </ul>  );} function show(metric: Metric) {  return metric.name === "CLS" ? metric.value.toFixed(3) : `${Math.round(metric.value)} ms`;}
Recorded from Next.js 16.4.3

Each step was taken in real Chrome against next start. Pick one to see the page after it.

localhost:3000/cartTea shop
Home Cart
Your cart

Your cart is empty.

  • TTFB on /: 167 ms (good)
  • FCP on /: 600 ms (good)
  • LCP on /: 600 ms (good)
  • FCP on /cart: 26 ms (good)
  • CLS on /: 0.000 (good)
  • INP on /: 96 ms (good)
Focus
the link “Cart”
Scrolled
0 px down

No requests during this step: the page already had what it needed

app/web-vitals.tsx is a Client Component: line 1 is "use client", because the numbers only exist in the browser. Line 4 imports the hook from next/web-vitals. Line 9 keeps a list of metrics in state. Lines 11 to 13 make report, a function that adds one metric to the list, and line 14 hands it to the hook, which calls it once for each metric it has measured. Lines 17 to 23 show the list, and line 20 writes each metric as its name, its page, its value and a rating. Line 15 of app/layout.tsx renders WebVitals in the root layout, so every page is measured.

Open the Steps tab. Step 1 opens /, step 2 clicks Add Mint tea, and step 3 clicks Cart. After step 3, the list has five lines for /: the three Core Web Vitals, and two helpers that come first, TTFB (Time to First Byte: when the first byte of the HTML arrived) and FCP (First Contentful Paint: when the browser first drew any text or image). The sixth line, FCP on /cart, is the cart's own. In this recording, LCP on / was 600 ms (good), CLS 0.000 (good) and INP 96 ms (good). These are timings from one visit on one computer, so they change from run to run.

Three browser windows of the same visit, left to right. Loading: the heading and a big photo, labeled LCP, the biggest thing drawn. Offer: an offer arrives and the teas below it move down, labeled CLS, how far things move. Click: Add Mint tea becomes Added, labeled INP, click to next paint.loadingTea shopmint tea photoofferTea shopoffer: 20% offteas, moved downclickMint tea: $3Add Mint teaAddedLCP: biggest thing drawnCLS: how far things moveINP: click to next paintgood: 2.5 s or lessgood: 0.1 or lessgood: 200 ms or less
Each Core Web Vital looks at one moment of a visit: when the biggest thing is drawn (LCP), how much moves without the visitor asking (CLS), and how long a click takes to show its result (INP).
Real-life analogyA dinner at a restaurant

A dinner can go wrong in three ways. The main dish takes forever. The table wobbles, so your glass slides just as you reach for it. Or you raise your hand and nobody comes. A restaurant that wants to know how it's doing doesn't ask one critic about one evening. It reads what most guests say, on busy nights too.

In real life: How long until the main dish is on the table
In Next.js: LCP: when the biggest thing on the screen is drawn
In real life: A wobbly table that makes the plates slide
In Next.js: CLS: content that moves while you look at it
In real life: How fast a waiter nods when you raise your hand
In Next.js: INP: how long a click takes to show its result
In real life: Reviews from every guest, not one critic's evening
In Next.js: Field data from real visitors, judged at the 75th percentile
The main idea

The Core Web Vitals are three numbers measured in real visitors' browsers: LCP for when the main content shows, CLS for how much the page jumps, and INP for how fast it answers a click. In Next.js, useReportWebVitals in a small Client Component reports them, and the usual fixes are in your own code: the photo you preload, the fallback you size, the work you move out of a click.

LCP, CLS and INP

Each metric watches one part of the visit:

  • LCP, Largest Contentful Paint, is the time from the start of the visit until the biggest image or block of text in the window has been drawn. It's usually the hero photo or the main heading. In the first recording, the whole page was drawn in its first paint, so LCP equals FCP in that recording.
  • CLS, Cumulative Layout Shift, scores how much visible content moves without the visitor asking for it. Each move scores how much of the window moved times how far it moved, and CLS is the worst burst of such moves during the visit. A move in the half second after a click or key press doesn't count: the visitor asked for that one. CLS has no unit.
  • INP, Interaction to Next Paint, is the time from a click, tap or key press until the browser draws the next frame. It adds up three parts: the input delay, while the browser finishes other work before it can start your event handlers; the processing time, while the handlers run; and the presentation delay, while it lays out and draws the next frame. That frame doesn't have to show the answer: INP ends at the first paint after the interaction, whatever it shows. A visit can have many interactions; INP reports one close to the slowest. It replaced an older metric, First Input Delay (FID), in March 2024.

Each metric has two thresholds from web.dev, Google's site about web performance. Up to the first, the value is good. Up to the second, it needs improvement. Above it, it's poor. Each row is one metric:

The Core Web Vitals and their thresholds
MetricWhat it measuresGoodNeeds improvementPoor
LCP, Largest Contentful PaintLoading: when the biggest image or text block in the window has been drawn.2.5 s or lessUp to 4 sMore than 4 s
CLS, Cumulative Layout ShiftSteadiness: how much visible content moves when nobody asked it to.0.1 or lessUp to 0.25More than 0.25
INP, Interaction to Next PaintResponse: how long the page takes to draw something after a click, tap or key press.200 ms or lessUp to 500 msMore than 500 ms

The rating on each line of the tea shop's list comes from those thresholds: the hook works them out for you, as "good", "needs-improvement" or "poor". TTFB and FCP have ratings too, though they aren't Core Web Vitals: they help you find why LCP is slow.

A site isn't judged by one visit. Every visitor has another phone, network and patience, so the numbers spread out. Google judges each metric at the 75th percentile: line all the visits up from fastest to slowest, and take the value three quarters of the way along. If that value is good, at least 75% of visits were good. Phones and computers are judged separately.

Eight cards in a row, the LCP of eight visits sorted from fastest to slowest: 1.2 s, 1.5 s, 1.8 s, 1.9 s, 2.1 s, 2.3 s, 2.6 s, 3.4 s. The sixth card, 2.3 s, is shaded and labeled 75th percentile. Under the first six: six of eight visits took 2.3 s or less. Under the last two: two slower visits.Eight visits to the home page, sorted by LCP1.2 s1.5 s1.8 s1.9 s2.1 s2.3 s2.6 s3.4 s75th percentilesix of eight: 2.3 s or lesstwo slower visits2.3 s at the 75th percentile: good
Eight visits, sorted. Six of the eight, 75%, took 2.3 s or less, so the page's LCP at the 75th percentile is 2.3 s: good, although one visit took 3.4 s.

Reporting them with useReportWebVitals

useReportWebVitals is a hook, so it must run in a Client Component. The docs suggest the shape the tea shop uses: a small file of its own with "use client" at the top, rendered once in the root layout. The layout stays a Server Component, and the client part is only that file and what it imports.

That file isn't free. Because it's in the root layout, every route loads its code and the web-vitals code it imports, and runs them after hydration. So keep it tiny: no UI library, no state it doesn't need, and nothing but the hook and the code that sends each metric away. The list in the tea shop's corner is there for the lesson; a real reporter, like the one below, renders nothing.

The hook calls your function with one object per metric. These are the fields you'll use most:

  • name: "LCP", "CLS", "INP", "FCP" or "TTFB".
  • value: the number, in milliseconds for all but CLS. rating: good, needs improvement or poor.
  • id: different for each metric of each page load, so a server that gets the same metric twice can tell.
  • navigationURL: the address the metric belongs to. The tea shop's list reads its path on line 20.

Keep the function the same from render to render. The docs warn that the hook calls a new function with the metrics measured so far, so a function written inline in the component would get them again on every render. Lines 11 to 13 of app/web-vitals.tsx use useCallback with no dependencies for that. A function outside the component works too.

When does each metric arrive? Look at the list after each step of the first recording again. Each row is one group of metrics:

When the recorded visit got each metric
MetricsWhen they arrivedWhy then
TTFB and FCPRight after the page loaded, in step 1.They can't change later, so the library reports them as soon as they're known.
LCPAt the first click, in step 2.A bigger thing can still appear until the visitor acts, so the library waits for the first click or key press.
CLS and INPWhen the visitor went to the cart, in step 3.Both keep changing while the page is open. The library reports them when the page is hidden or, in a browser that reports client-side navigations, when the visitor moves to another page.

The hook is short. In 16.4.3, it's packages/next/src/client/web-vitals.ts: an Effect that hands your function to onCLS, onLCP, onINP, onFCP and onTTFB from a copy of Google's web-vitals library that ships inside Next.js. That library listens to the browser's own measurements with a PerformanceObserver, such as its largest-contentful-paint and layout-shift entries, and turns them into the numbers above. The first four calls pass reportSoftNavs: true. A soft navigation is a page change without a new document, like the one a Link makes. The Chrome that made these recordings reports them, so clicking Cart ended the measurements for / and started new ones for /cart: that's where FCP on /cart comes from. In a browser that doesn't, everything after a client-side navigation still counts for the page the visitor opened first, and CLS and INP arrive when the tab is hidden or closed.

Your numbers, and Google's report

What the hook reported here is your own measurement, split at each soft navigation because Chrome supports it. Google's stable Core Web Vitals, the ones CrUX, PageSpeed Insights and Search Console report, still don't split at soft navigations: they count the first page load, and the interactions and shifts after a client-side navigation go to the page the visitor opened first. Since October 2026, CrUX also publishes experimental versions that include soft navigations, beside the stable ones, which haven't changed. So your own numbers per page and Google's report can differ for a Next.js app with many Link clicks, so check which kind of number you're looking at.

Sending them to your own server

A list on the page helps you learn; real visitors' numbers belong on your server, where you can add them up. Here the shop sends each metric to a Route Handler of its own:

Each metric sent to the shop's own endpointNext.js
app/api/vitals/cart/
app/web-vitals.tsx
"use client"; import { useReportWebVitals } from "next/web-vitals"; type Metric = Parameters<Parameters<typeof useReportWebVitals>[0]>[0]; // Outside the component, so it's the same function on every render.function send(metric: Metric) {  const body = JSON.stringify(metric);  // keepalive lets the request finish even when the visitor is leaving the page.  fetch("/api/vitals", { method: "POST", body, keepalive: true });} export function WebVitals() {  useReportWebVitals(send);  return null;}
Recorded from Next.js 16.4.3
$ next build▲ Next.js 16.4.3 (Turbopack)- Cache Components enabled- Partial Prefetching enabled   Creating an optimized production build ...✓ Compiled successfully  Running TypeScript ...  Finished TypeScript ...  Collecting page data ...✓ Generating static pages (5/5)  Finalizing page optimization ... Route (app)┌ ○ /├ ○ /_not-found├ ƒ /api/vitals└ ○ /cart ○  (Static)   prerendered as static contentƒ  (Dynamic)  server-rendered on demand $ next start▲ Next.js 16.4.3- Local:         http://127.0.0.1:3000- Network:       http://127.0.0.1:3000✓ ReadyTTFB on /: 314 ms (good)FCP on /: 824 ms (good)LCP on /: 824 ms (good)FCP on /cart: 34 ms (good)CLS on /: 0.000 (good)INP on /: 88 ms (good)

In app/web-vitals.tsx, send on lines 8 to 12 sits outside the component, so it's the same function on every render. Line 9 turns the metric into JSON, and line 11 posts it to /api/vitals with fetch. keepalive: true lets the request finish even when the visitor is leaving the page, which is exactly when CLS and INP are often sent. The component returns null: it shows nothing. The docs' version uses navigator.sendBeacon, which also survives the page closing, and falls back to this fetch; here fetch lets the recording list each request.

In app/api/vitals/route.ts, line 6 prints each metric in the server's log. Open the Terminal tab: after the build comes what next start printed while real Chrome made the visit, one line per metric, in the order they arrived. The Steps tab has the POST /api/vitals requests that carried them.

The endpoint is public

Anyone can post to /api/vitals, not only your pages. The Route Handler here is a stand-in that only prints. A real one checks that the body is a metric it expects, limits how often one visitor can send, and passes the metric on to wherever you keep them, an analytics service or a database. Work like that can run after the response so the browser isn't kept waiting.

Lab data and field data

Numbers like the ones above come in two kinds. Lab data comes from a test you run: one visit, on a device and network you pick. Field data comes from your real visitors, on their own phones and connections. Each row asks the same question of both:

Lab data and field data
QuestionLab dataField data
Who visits the page?A test: one visit, on one device and network you pick.Your real visitors, on their own phones, computers and networks.
Which tools give it?Lighthouse, the Performance panel in Chrome's DevTools, and the tea shop's recordings.CrUX, which PageSpeed Insights and Search Console show, and your own reporting with useReportWebVitals.
What about INP?Only when the test clicks. Lighthouse loads the page without clicking, so it reports Total Blocking Time instead.Measured from the clicks, taps and key presses real visitors make.
When is it useful?Before you ship, and to find out why a page is slow.To know how the site really feels, and whether a fix worked for real people.

The tea shop's recordings are lab data, and very little of it: one visit, in Chrome with no window, on a fast computer that was busy with other work, talking to a server on the same computer. They show what changes when the code changes, like the order in which Chrome asked for files, or whether the teas moved. They don't show what your visitors feel. A phone on a train can get bigger numbers.

Field data has two common sources:

  • CrUX, the Chrome User Experience Report: Google's collection of real visits from Chrome users who agreed to share usage statistics, for public pages with enough visitors, over the last 28 days. PageSpeed Insights shows a page's CrUX numbers at the top and a Lighthouse lab run below them; Search Console has a Core Web Vitals report from the same data.
  • Your own reporting, often called RUM (real user monitoring): useReportWebVitals sending to your server, like the tea shop's /api/vitals. It covers every page, including ones CrUX never sees, like pages behind a sign-in, and you can split the numbers by page, device or release.
Find it in the lab, confirm it in the field

Use the lab to find why a page is slow and to check a fix before you ship it. Then watch the field numbers at the 75th percentile: only they say whether the fix helped real visitors. CrUX covers the last 28 days, so a fix shows there fully only after 28 days; your own reporting shows the change the day you ship.

Fixing a slow LCP

LCP is a chain. The HTML must arrive, the browser must ask for the LCP image (if it's an image), the file must download, and then it must be drawn. A delay anywhere in the chain makes LCP late. web.dev names the four parts: time to first byte, the delay before the image is asked for, the download, and the delay before drawing.

The second part is the one Next.js code changes most easily. Here the home page opens with a big photo of mint tea. On a fast connection, the difference is hard to see, so the recorder first made Chrome's network slow: each request waited 1.5 seconds, like a weak phone signal. Pick each version, then read step 2's list of requests and step 4's LCP:

Playground: when is the big photo drawn?
The home page with a big photo, on a slow connectionNext.js
app/cart/public/
app/page.tsx
import Image from "next/image";import { AddToCart } from "./add-to-cart"; const teas = [  { name: "Green tea", price: 4 },  { name: "Mint tea", price: 3 },  { name: "Lemon tea", price: 3 },]; export default function Home() {  return (    <main>      <Image        src="/mint-tea.jpg"        alt="A cup of mint tea on a saucer"        width={1600}        height={1067}        sizes="100vw"        style={{ width: "100%", height: "auto" }}      />      <h1>Tea shop</h1>      <ul>        {teas.map((tea) => (          <li key={tea.name}>            {tea.name}: ${tea.price} <AddToCart name={tea.name} />          </li>        ))}      </ul>    </main>  );}
Recorded from Next.js 16.4.3

Each step was taken in real Chrome against next start. Pick one to see the page after it.

localhost:3000/Tea shop
Home Cart
A cup of mint tea on a saucer1600 × 1067
Tea shop
  • Green tea: $4 Add Green tea
  • Mint tea: $3 Added
  • Lemon tea: $3 Add Lemon tea
  • FCP on /: 3124 ms (poor)
  • TTFB on /: 84 ms (good)
  • LCP on /: 4688 ms (poor)
Focus
the button “Added”
Scrolled
208 px down

No requests during this step: the page already had what it needed

Try it yourself
Nothing

The photo is lazy, next/image's default. In step 2's list, Chrome asked for it last, after the stylesheet and every script: a lazy photo waits until the browser has laid out the page and seen it's on screen.

Each version was built with next build from Next.js 16.4.3 and opened in real Chrome on a slow connection: every request waited 1.5 s. Step 2 lists every file Chrome asked for, in order; step 4 has LCP. Only line 20 changes. The numbers change from run to run. Reset goes back to the first version.

Without preload, the photo is lazy, like every next/image by default: the browser asks for it only after it has laid out the page and seen that the photo is on the screen. In step 2's list, the photo's request comes last, after the stylesheet and every script. With preload on line 20, Next.js puts a <link rel="preload" as="image"> in the head, and the photo's request comes right after the HTML. In this recording, LCP on / was 4,688 ms (poor) without preload, and 3,128 ms (needs-improvement) with it, in this recording only: the numbers change from run to run. What the playground shows is the order of the requests, and that preload beat the photo left lazy. It doesn't compare preload with the other two fixes below.

The real problem was the lazy default, and next/image gives three ways out. The docs say that in most cases, loading="eager" or fetchPriority="high" is the one to use:

  • loading="eager": load the photo at once, wherever it is on the page. Fits a photo that is surely on the first screen.
  • fetchPriority="high": ask the browser to fetch it before other images. Fits the photo that is most likely the LCP.
  • preload: also put a preload link in the head, so the download starts before the browser reaches the <img>. Fits one photo that is surely the LCP on every screen size. Don't combine it with loading or fetchPriority, and don't use it on several photos that could each be the LCP.

In Next.js 16, preload replaced priority, which still works and is deprecated. Optimizing images shows the HTML each of the three writes. Preloaded files compete with each other, and with the page's other files, for the same network.

The other parts of the chain have their own fixes:

  • A late first byte. If every request waits for slow data before any HTML is sent, everything is late. Keep the page's static shell prerendered, and let the slow part stream in behind a Suspense boundary (Partial Prerendering explains the shell).
  • An LCP that needs JavaScript. A heading or photo that a Client Component fetches in an Effect can only be drawn after the JavaScript has loaded and run. Render it in a Server Component, so it's in the HTML.
  • A heading in a web font. next/font preloads the font file and draws the text in a matched fallback until it arrives (Fonts in Next.js).
  • A photo bigger than needed. sizes lets the browser pick a smaller file from the srcset, so the download is shorter.

Fixing layout shift

The tea shop now shows each visitor an offer, read from a cookie. The offer comes from a slow service, so it streams in a second after the rest of the page, behind a Suspense boundary. Before it arrives, the boundary shows its fallback. Pick each fallback, then read step 3's list:

Playground: does the page jump when the offer arrives?
The home page, with this Suspense fallback on line 18Next.js
app/cart/
app/page.tsx
import { Suspense } from "react";import { AddToCart } from "./add-to-cart";import { Offer } from "./offer"; const teas = [  { name: "Green tea", price: 4 },  { name: "Mint tea", price: 3 },  { name: "Lemon tea", price: 3 },  { name: "Oolong", price: 5 },  { name: "Chamomile", price: 4 },  { name: "Jasmine", price: 5 },]; export default function Home() {  return (    <main>      <h1>Tea shop</h1>      <Suspense fallback={<p>Loading your offer…</p>}>        <Offer />      </Suspense>      <ul>        {teas.map((tea) => (          <li key={tea.name}>            {tea.name}: ${tea.price} <AddToCart name={tea.name} />          </li>        ))}      </ul>    </main>  );}
Recorded from Next.js 16.4.3

Each step was taken in real Chrome against next start. Pick one to see the page after it.

localhost:3000/cartTea shop
Home Cart
Your cart

Your cart is empty.

  • FCP on /: 224 ms (good)
  • TTFB on /: 82 ms (good)
  • LCP on /: 1152 ms (good)
  • FCP on /cart: 38 ms (good)
  • CLS on /: 0.115 (needs-improvement)
  • INP on /: 72 ms (good)
Focus
the link “Cart”
Scrolled
0 px down

No requests during this step: the page already had what it needed

Try it yourself
One line

The fallback is one line. A second later, the 300-pixel offer takes its place, and the six teas below jump down. After step 3, CLS on / is above 0.1: needs improvement.

Each version was built with next build from Next.js 16.4.3 and visited in real Chrome: open /, click Add Mint tea, then click Cart. The Steps tab shows the metrics list after each step. Only the fallback changes. The numbers change a little from run to run. Reset goes back to the first version.

In app/offer.tsx, Offer reads the cookie on line 5, waits a second on line 7, and returns a section with the class offer, which offer.module.css makes 300 pixels tall. With the one-line fallback on line 18 of app/page.tsx, the six teas sit right under Loading your offer…, and jump down when the offer takes its place. In this recording, CLS on / was 0.115 (needs-improvement). With OfferSkeleton, on lines 16 to 22 of app/offer.tsx, the fallback has the same class and the same height, so the teas start where they'll stay. CLS was 0.000 (good).

Two halves. Left, a one-line fallback: first the heading, Loading, and the teas high up; later the tall offer, and the teas lower down, labeled the teas jump down. Right, a skeleton: first the heading, a skeleton as tall as the offer, and the teas; later the offer in the skeleton's place and the teas where they were, labeled nothing moves.firstTea shopLoading…teaslaterTea shopofferteasthe teas jump downfallback: one linefirstTea shopskeletonteaslaterTea shopofferteasnothing movesfallback: the offer's size
The fallback holds the offer's place until it streams in. If it's one line, the teas below jump down when the offer arrives. If it's as tall as the offer, they stay where they are.

Both versions have something else in common. The offer's heading is the biggest text on the screen, and it arrives a second late, so LCP on / waited for it: 1,152 ms (good) and 1,376 ms (good). Streaming lets the rest of the page show at once; it doesn't make the streamed part sooner. When you can, keep the main content in the part that doesn't wait.

A skeleton is one fix. The others follow the same rule, keep room for what's coming:

  • Fallbacks the size of their content: a loading.tsx or Suspense fallback with the page's own shape (Loading UI builds one).
  • Images with their size: width and height, or fill in a box of a known size, so the browser keeps the photo's room before the file arrives (Optimizing images).
  • Fonts with a matched fallback: next/font draws text in a local font resized with size-adjust until the web font arrives, so the lines don't change length when it swaps (Fonts in Next.js).
  • Nothing pushed in above: a cookie banner or a late message goes over the page or at the end, not above content the visitor is already reading. The tea shop's own metrics list is position: fixed in the corner for the same reason: when it grows, nothing else moves.

A slow click and INP

After a click, the browser runs your event handler, then draws the next frame. Until the handler is done, it can't draw anything, so a handler that does a lot of work makes the click feel ignored. Here the Add button also updates a list of suggestions, which takes 300 ms of busy JavaScript. Pick each order of work:

Playground: work first, or paint first?
app/add-to-cart.tsx, with this order of workNext.js
app/cart/
app/add-to-cart.tsx
"use client"; import { useState } from "react"; export function AddToCart({ name }: { name: string }) {  const [added, setAdded] = useState(false);   function add() {    updateSuggestions();    setAdded(true);  }   return <button onClick={add}>{added ? "Added" : `Add ${name}`}</button>;} // Stands in for slow work, like sorting a long list of suggestions: 300 ms of busy JavaScript.function updateSuggestions() {  const end = performance.now() + 300;  while (performance.now() < end) {}}
Recorded from Next.js 16.4.3

Each step was taken in real Chrome against next start. Pick one to see the page after it.

localhost:3000/cartTea shop
Home Cart
Your cart

Your cart is empty.

  • TTFB on /: 1042 ms (needs-improvement)
  • FCP on /: 2404 ms (needs-improvement)
  • LCP on /: 2420 ms (good)
  • FCP on /cart: 39 ms (good)
  • CLS on /: 0.000 (good)
  • INP on /: 440 ms (needs-improvement)
Focus
the link “Cart”
Scrolled
0 px down

No requests during this step: the page already had what it needed

Try it yourself
Work first

Line 9 runs 300 ms of work before line 10 updates the state, so the browser can't paint Added until the work is done: INP can't be under 300 ms. After step 3, INP on / shows it.

Each version was built with next build from Next.js 16.4.3 and visited in real Chrome: open /, click Add Mint tea, then click Cart. INP shows after step 3. Only the add function changes. The numbers change a little from run to run. Reset goes back to the first version.

With the work first, line 9 runs updateSuggestions before line 10 changes the state, and the browser paints Added only after both: the 300 ms are processing time, so INP can't be under 300 ms. With the paint first, line 9 changes the state, React updates the button, and line 11 waits: requestAnimationFrame runs just before the next frame is drawn, and the setTimeout inside it starts the work right after that frame. The work takes as long as before; it just no longer stands between the click and the next paint, so INP drops far below. In this recording only, INP on / was 440 ms (needs-improvement) with the work first and 72 ms (good) with the paint first; the numbers change from run to run. (The event loop explains how tasks and frames take turns.)

Other ways to keep clicks fast:

  • Do less work in the browser: sort, filter or format on the server, where Server Components do it before the HTML is sent.
  • Mark a slow re-render as a transition, so React can stop rendering it to handle the next click.
  • Keep third-party scripts that keep the browser busy off the pages that don't need them.

Which number does it hurt?

Before launch, the tea shop's team lists what could go wrong. Each problem makes one metric worse. Sort them by the metric they hurt.

Which Core Web Vital does it hurt?
  • The big photo at the top keeps next/image's default, loading="lazy"
  • Every request waits for a slow database query before the first byte is sent
  • The page's big heading is fetched in a Client Component's Effect
  • A Suspense fallback is one line, and the content that replaces it is 300 pixels tall
  • An <img> with no width or height above the price
  • A cookie banner is inserted above the page a second after it loads
  • A click handler sorts 50,000 rows before it updates the button
  • A third-party script keeps the browser busy for long stretches
Try it yourself
0 of 8 correct

Sort each problem by the metric it makes worse: LCP, CLS or INP.

Choose a category for every card. You can change an answer at any time; Reset clears them all.

Common mix-ups

  • "The Core Web Vitals come from Lighthouse." No. They're measured from real visits first. Lighthouse is a lab test of one visit, and it can't measure INP without clicks.
  • "A good score on my laptop means visitors are fine." No. Your laptop is one fast visit. What counts is the 75th percentile of real visits, on slower phones and networks.
  • "useReportWebVitals can go in a Server Component." No. The numbers exist only in the browser, so the hook runs in a Client Component: a small file of its own, rendered in the root layout.
  • "Streaming always makes a page better." It shows the rest of the page sooner, but a fallback of the wrong size makes the page jump, and a streamed block that is the biggest thing on the screen delays LCP.
  • "preload every image to be safe." No. Preload the one photo that is the LCP. Preloaded photos compete for the same connection, and lazy loading saves the others for visitors who scroll.
  • "INP is the delay of the first click." That was First Input Delay, which INP replaced in 2024. INP looks at the whole visit, and at the time until the next paint, not only until the handler starts.

Practice exercises

These exercises are about Next.js projects, which can't run inside this page. Answer first, then use the hints.

Exercise 1 · Warm-upRate three numbers

A page measured these values at the 75th percentile. Which of the three is good?

Starter codeText
LCP: 3,100 ms
CLS: 0.04
INP: 260 ms

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · Warm-upWhen does CLS arrive?

    In the first recording, after which step does the list first show a line for CLS on /?

    Starter codeText
    Step 1: open /
    Step 2: click Add Mint tea
    Step 3: click Cart

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeA count that never stops

      This WebVitals counts the metrics it gets. Fix it, then say how many metrics it shows right after the page has loaded, before any click.

      Starter codeTSX
      "use client";
      
      import { useState } from "react";
      import { useReportWebVitals } from "next/web-vitals";
      
      export function WebVitals() {
        const [count, setCount] = useState(0);
        useReportWebVitals((metric) => {
          console.log(metric.name, metric.value);
          setCount((n) => n + 1);
        });
        return <p>{count} metrics</p>;
      }
      

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticeGive the fallback the offer's size

        Change this page so the teas don't jump when the offer arrives. What CLS did the fixed page measure?

        Starter codeTSX
        import { Suspense } from "react";
        import { AddToCart } from "./add-to-cart";
        import { Offer } from "./offer";
        
        const teas = [
          { name: "Green tea", price: 4 },
          { name: "Mint tea", price: 3 },
          { name: "Lemon tea", price: 3 },
          { name: "Oolong", price: 5 },
          { name: "Chamomile", price: 4 },
          { name: "Jasmine", price: 5 },
        ];
        
        export default function Home() {
          return (
            <main>
              <h1>Tea shop</h1>
              <Suspense fallback={<p>Loading your offer…</p>}>
                <Offer />
              </Suspense>
              <ul>
                {teas.map((tea) => (
                  <li key={tea.name}>
                    {tea.name}: ${tea.price} <AddToCart name={tea.name} />
                  </li>
                ))}
              </ul>
            </main>
          );
        }
        

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengeThe photo that starts last

          On a slow connection, this page's LCP is poor. Which one prop on the photo fixes the late start?

          Starter codeTSX
          import Image from "next/image";
          import { AddToCart } from "./add-to-cart";
          
          const teas = [
            { name: "Green tea", price: 4 },
            { name: "Mint tea", price: 3 },
            { name: "Lemon tea", price: 3 },
          ];
          
          export default function Home() {
            return (
              <main>
                <Image
                  src="/mint-tea.jpg"
                  alt="A cup of mint tea on a saucer"
                  width={1600}
                  height={1067}
                  sizes="100vw"
                  style={{ width: "100%", height: "auto" }}
                />
                <h1>Tea shop</h1>
                <ul>
                  {teas.map((tea) => (
                    <li key={tea.name}>
                      {tea.name}: ${tea.price} <AddToCart name={tea.name} />
                    </li>
                  ))}
                </ul>
              </main>
            );
          }
          

          Answer, then press Check. Spacing and letter case don’t matter.

            Exercise 6 · ChallengePaint before the work

            This button's INP is over 300 ms. Which call in add must come first to fix it?

            Starter codeTSX
            "use client";
            
            import { useState } from "react";
            
            export function AddToCart({ name }: { name: string }) {
              const [added, setAdded] = useState(false);
            
              function add() {
                updateSuggestions();
                setAdded(true);
              }
            
              return <button onClick={add}>{added ? "Added" : `Add ${name}`}</button>;
            }
            
            // Stands in for slow work, like sorting a long list of suggestions: 300 ms of busy JavaScript.
            function updateSuggestions() {
              const end = performance.now() + 300;
              while (performance.now() < end) {}
            }
            

            Answer, then press Check. Spacing and letter case don’t matter.

              Check your understanding

              The Core Web Vitals quiz · 7 questionsScore: first tries count
              1. Question 1 of 7At the 75th percentile of real visits, which LCP counts as good?

                Choose an answer to see the explanation.

              2. Question 2 of 7What does CLS add up?

                Choose an answer to see the explanation.

              3. Question 3 of 7In the recorded visit, when did useReportWebVitals hand over LCP?

                Choose an answer to see the explanation.

              4. Question 4 of 7Lighthouse loads a page once, in the lab, and never clicks. Which Core Web Vital can't it give you?

                Choose an answer to see the explanation.

              5. Question 5 of 7Why does the recorded WebVitals wrap its callback in useCallback with no dependencies?

                Choose an answer to see the explanation.

              6. Question 6 of 7A Suspense fallback is one line tall, and the offer that replaces it is 300 pixels tall, above the list of teas. What does the change do to CLS?

                Choose an answer to see the explanation.

              7. Question 7 of 7Why did INP drop with this button?

                Read the code, then predictTSX
                "use client";
                
                import { useState } from "react";
                
                export function AddToCart({ name }: { name: string }) {
                  const [added, setAdded] = useState(false);
                
                  function add() {
                    setAdded(true);
                    // Let the browser paint "Added" first, then do the slow work.
                    requestAnimationFrame(() => setTimeout(updateSuggestions));
                  }
                
                  return <button onClick={add}>{added ? "Added" : `Add ${name}`}</button>;
                }
                
                // Stands in for slow work, like sorting a long list of suggestions: 300 ms of busy JavaScript.
                function updateSuggestions() {
                  const end = performance.now() + 300;
                  while (performance.now() < end) {}
                }
                

                Choose an answer to see the explanation.

              Key takeaways

              • LCP is when the biggest thing on the screen is drawn: good is 2.5 s or less.
              • CLS scores content that moves without the visitor asking: good is 0.1 or less.
              • INP is the time from a click, tap or key press to the next paint: good is 200 ms or less.
              • Sites are judged at the 75th percentile of real visits, phones and computers separately.
              • useReportWebVitals runs in a small Client Component in the root layout. Give it a function that stays the same between renders.
              • Lab tests find problems; field data from CrUX or your own reporting says whether visitors feel them.
              • Preload the LCP photo, give fallbacks and images their size, and let a click paint before slow work.

              Measure in the browser, judge at the 75th percentile, fix in your code.
              LCP, CLS and INP say how a page loads, holds still and answers. useReportWebVitals reports them from real visits.

              Coming next: Shrinking the JavaScript bundle. The less JavaScript a page loads, the sooner it can answer clicks.

              CompleteFrontend Clear concepts. Working examples.