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.
- 01Read the three numbersSay what LCP, CLS and INP measure, their good thresholds, and why they're judged at the 75th percentile of real visits.
- 02Measure them in your appReport every metric with useReportWebVitals from a small Client Component, and send it to an endpoint of your own.
- 03Fix 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:
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`;}Each step was taken in real Chrome against next start. Pick one to see the page after it.
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.
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 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:
| Metric | What it measures | Good | Needs improvement | Poor |
|---|---|---|---|---|
| LCP, Largest Contentful Paint | Loading: when the biggest image or text block in the window has been drawn. | 2.5 s or less | Up to 4 s | More than 4 s |
| CLS, Cumulative Layout Shift | Steadiness: how much visible content moves when nobody asked it to. | 0.1 or less | Up to 0.25 | More than 0.25 |
| INP, Interaction to Next Paint | Response: how long the page takes to draw something after a click, tap or key press. | 200 ms or less | Up to 500 ms | More 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.
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:
| Metrics | When they arrived | Why then |
|---|---|---|
| TTFB and FCP | Right after the page loaded, in step 1. | They can't change later, so the library reports them as soon as they're known. |
| LCP | At 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 INP | When 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.
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:
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;}$ 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.
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:
| Question | Lab data | Field 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):
useReportWebVitalssending 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.
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:
app/page.tsximport 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> );}Each step was taken in real Chrome against next start. Pick one to see the page after it.
- Green tea: $4
- Mint tea: $3
- Lemon tea: $3
- 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
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.
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 withloadingorfetchPriority, 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.
sizeslets the browser pick a smaller file from thesrcset, 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:
app/page.tsximport { 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> );}Each step was taken in real Chrome against next start. Pick one to see the page after it.
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
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.
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).
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.tsxorSuspensefallback with the page's own shape (Loading UI builds one). - Images with their size:
widthandheight, orfillin 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-adjustuntil 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: fixedin 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:
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) {}}Each step was taken in real Chrome against next start. Pick one to see the page after it.
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
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.
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.
- 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
Sort each problem by the metric it makes worse: LCP, CLS or INP.
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.
A page measured these values at the 75th percentile. Which of the three is good?
LCP: 3,100 ms
CLS: 0.04
INP: 260 msOnly CLS. 0.04 is under 0.1, so it's good. LCP at 3,100 ms is between 2.5 s and 4 s, so it needs improvement, and INP at 260 ms is between 200 ms and 500 ms, so it needs improvement too.
In the first recording, after which step does the list first show a line for CLS on /?
Step 1: open /
Step 2: click Add Mint tea
Step 3: click CartStep 3. Clicking Cart is a soft navigation, which ends the measurements for /, so the hook reports CLS and INP for / then. The first recording's list shows CLS on / only after step 3.
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.
"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>;
}
"use client";
import { useCallback, useState } from "react";
import { useReportWebVitals } from "next/web-vitals";
export function WebVitals() {
const [count, setCount] = useState(0);
const report = useCallback((metric: { name: string; value: number }) => {
console.log(metric.name, metric.value);
setCount((n) => n + 1);
}, []);
useReportWebVitals(report);
return <p>{count} metrics</p>;
}
With the inline function, each metric changes the count, the new render makes a new function, and the hook starts measuring again and reports the metrics again: a loop that never settles, so the count keeps growing for as long as the page is open (this recording read 6616 metrics a moment after loading). With useCallback, the function stays the same and each metric is reported once: right after loading, that's TTFB and FCP, and the recorded page showed 2 metrics.
Change this page so the teas don't jump when the offer arrives. What CLS did the fixed page measure?
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>
);
}
import { Suspense } from "react";
import { AddToCart } from "./add-to-cart";
import { Offer, OfferSkeleton } 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={<OfferSkeleton />}>
<Offer />
</Suspense>
<ul>
{teas.map((tea) => (
<li key={tea.name}>
{tea.name}: ${tea.price} <AddToCart name={tea.name} />
</li>
))}
</ul>
</main>
);
}
Line 3 imports OfferSkeleton, and line 18 uses it as the fallback. It's as tall as the offer, so nothing below moves when the offer arrives. The recording of this version measured CLS on / as 0.000.
On a slow connection, this page's LCP is poor. Which one prop on the photo fixes the late start?
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>
);
}
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" }}
preload
/>
<h1>Tea shop</h1>
<ul>
{teas.map((tea) => (
<li key={tea.name}>
{tea.name}: ${tea.price} <AddToCart name={tea.name} />
</li>
))}
</ul>
</main>
);
}
preload, on line 20. The photo's request then comes right after the HTML instead of after every script, and in the slow-connection recording LCP came earlier with it.
This button's INP is over 300 ms. Which call in add must come first to fix it?
"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) {}
}
"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) {}
}
setAdded(true) comes first, and requestAnimationFrame(() => setTimeout(updateSuggestions)) starts the work after the next frame is drawn. With the work first, its 300 ms are part of INP; painting first takes them out, so INP drops far below 300 ms (in this recording only, under 200 ms).
Check your understanding
Question 1 of 7At the 75th percentile of real visits, which LCP counts as good?
Choose an answer to see the explanation.
Question 2 of 7What does CLS add up?
Choose an answer to see the explanation.
Question 3 of 7In the recorded visit, when did
useReportWebVitalshand over LCP?Choose an answer to see the explanation.
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.
Question 5 of 7Why does the recorded
WebVitalswrap its callback inuseCallbackwith no dependencies?Choose an answer to see the explanation.
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.
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.
useReportWebVitalsruns 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.