Three numbers decide your score. Most advice ignores which one you are failing.
People send me a Lighthouse screenshot and ask what to fix. The screenshot is usually the wrong document. What you want is field data for the specific metric you are failing, because the fix for a slow image has nothing in common with the fix for a blocked main thread.
The three numbers
Google scores three things. Largest Contentful Paint measures when the biggest visible element finishes rendering. Interaction to Next Paint measures how long the page takes to respond visually after someone taps or clicks. Cumulative Layout Shift measures how much the page jumps around while it loads.
Interaction to Next Paint replaced First Input Delay in March 2024. If you are working from an older article that talks about FID, the advice in it is probably out of date, because FID only measured input delay while INP measures the whole path to a visible response.
Each metric has a good band and a poor band. To pass, the 75th percentile of your real visits has to sit in the good band. That percentile is the part people miss. Your median visitor can be having a fine time while you still fail, because one visitor in four is having a worse one.
Field data, not lab data
Lighthouse runs a simulated load on your machine, over your connection, with your CPU. It is a debugging tool. It tells you what could be slow. It does not tell you what is slow for the people using your site.
Field data comes from the Chrome User Experience Report, which is collected from real Chrome users. That is what Search Console shows you, and it is what Google uses.
| Source | What it measures | Use it for |
|---|---|---|
| Lighthouse / PageSpeed lab | One simulated load on one device | Finding the cause after you know the symptom |
| Search Console Core Web Vitals | Real visits, grouped by URL pattern | Deciding what to work on at all |
| CrUX dashboard or API | Real visits, 28 day rolling window | Tracking whether a fix actually landed |
| web-vitals in your own analytics | Real visits, your own segmentation | Catching regressions within days, not weeks |
Fixing LCP
On a content site, the largest element is almost always the hero image, the featured image or a big block of heading text. Find out which before you change anything. PageSpeed Insights names the element for you.
If it is an image
The browser has to discover the image, download it, then paint it. Every one of those three steps is worth attacking.
import Image from 'next/image'; // priority tells Next.js to preload this image and skip lazy loading.// Use it on the one image that is your LCP element, and nowhere else.<Image src={post.cover} alt={post.coverAlt} width={1200} height={630} priority sizes="(max-width: 768px) 100vw, 800px"/>;Two mistakes here are common. Putting priority on several images, which makes them compete and helps none of them. And leaving sizes off, which makes the browser download a desktop sized file for a phone.
If the image sits on a different domain, the connection setup costs you real time before the download even starts. A preconnect hint fixes that.
<head> {/* Warm the connection to wherever your images come from */} <link rel="preconnect" href="https://cdn.example.com" /></head>If it is text
Then your font is the problem. A webfont that loads late means the text either sits invisible or renders in a fallback and then swaps, and the LCP clock keeps running until the real font paints.
import localFont from 'next/font/local'; export const sans = localFont({ src: [{ path: '../../public/fonts/Inter-Variable.woff2', weight: '100 900' }], // Render immediately in a fallback rather than showing nothing display: 'swap', preload: true, // Trim the gap between fallback metrics and the real font so the // swap does not move the text and cost you layout shift instead adjustFontFallback: 'Arial',});- Self host the font. A request to a third party font host adds a DNS lookup, a TLS handshake and a round trip before a single byte of font arrives.
- Ship only the weights you use. Four weights of a variable font is usually one file. Four separate static files is four downloads.
- Subset to the characters you need. A Latin only subset is a fraction of the full file.
Fixing INP
INP is a JavaScript problem in nearly every case I have looked at. Someone taps, and the browser cannot paint the response because the main thread is busy running code.
On a content site the fix is usually to send less JavaScript rather than to optimise the JavaScript you send. In the App Router, components are server components unless you mark them otherwise, and a server component ships no JavaScript at all.
// No 'use client' at the top, so none of this reaches the browser as JS.// The interactive bits are imported as their own small client components.import CommentForm from './CommentForm'; // 'use client' lives in thereimport ShareButtons from './ShareButtons'; // and in there export default async function PostPage({ params }) { const { slug } = await params; const post = await getPost(slug); return ( <article> <h1>{post.title}</h1> <div dangerouslySetInnerHTML={{ __html: post.html }} /> <ShareButtons url={post.url} /> <CommentForm postId={post.id} /> </article> );}Anything heavy that is not visible on first load should not be in the initial bundle. Load it when it is needed instead.
'use client'; import dynamic from 'next/dynamic'; // A comment widget nobody sees until they scroll. Splitting it out// keeps it off the critical path entirely.const Thread = dynamic(() => import('./Thread'), { ssr: false, loading: () => <p className="text-sm text-gray-500">Loading comments</p>,});Third party scripts
Analytics, chat widgets, tag managers and embeds are usually the largest single cause of a bad INP on an otherwise clean site. They run on the same main thread as your page. Load them after your page is interactive.
import Script from 'next/script'; {/* afterInteractive keeps it out of the critical path. lazyOnload pushes it even later, to the browser idle period. */}<Script src="https://example.com/analytics.js" strategy="afterInteractive" /><Script src="https://example.com/chat-widget.js" strategy="lazyOnload" />Audit these honestly. A chat widget that produces two conversations a month is not worth 400 KB of main thread work on every visit. That is a business decision, not a technical one, and it should be made by somebody who can see both numbers.
Fixing CLS
Layout shift comes from content arriving without space reserved for it. The browser lays out the page, then something loads, then everything below it moves down.
The usual causes, in the order I check them:
| Cause | What you see | Fix |
|---|---|---|
| Image without dimensions | Text jumps down when the image appears | Always set width and height, or an aspect-ratio |
| Webfont swap | Text reflows slightly once the real font loads | Match fallback metrics with adjustFontFallback |
| Injected banner or notice | Whole page shoves down after load | Reserve the height, or position it out of flow |
| Ad or embed slot | Content moves as each slot fills | Give every slot a fixed min-height |
| Late loading web component | A gap fills in after everything else | Render a placeholder of the same size |
// Reserving the space before the image arrives means nothing moves// when it does. The wrapper holds the shape, the image fills it.<div className="relative w-full" style={{ aspectRatio: '16 / 9' }}> <Image src={src} alt={alt} fill sizes="(max-width: 768px) 100vw, 800px" /></div>Measuring it yourself
Search Console will tell you the truth eventually. To see a fix land sooner, report the metrics from real sessions into whatever analytics you already run.
'use client'; import { useReportWebVitals } from 'next/web-vitals'; export default function Vitals() { useReportWebVitals((metric) => { // metric.name is one of LCP, INP, CLS, FCP, TTFB // metric.rating is 'good' | 'needs-improvement' | 'poor' const body = JSON.stringify({ name: metric.name, value: metric.value, rating: metric.rating, path: window.location.pathname, }); // sendBeacon survives the page being closed mid-send if (navigator.sendBeacon) { navigator.sendBeacon('/api/vitals', body); } else { fetch('/api/vitals', { body, method: 'POST', keepalive: true }); } }); return null;}Collect the 75th percentile per URL pattern rather than a site wide average. An average hides exactly the problem you are looking for, because your fastest pages will mask your slowest ones.
-- The number Google actually scores you on, per templateSELECT path_pattern, metric, APPROX_QUANTILES(value, 100)[OFFSET(75)] AS p75, COUNT(*) AS samplesFROM vitalsWHERE recorded_at > NOW() - INTERVAL '28 days'GROUP BY path_pattern, metricHAVING COUNT(*) > 100 -- ignore patterns with too little trafficORDER BY p75 DESC;Sort by the worst number and work down the list. That sounds obvious, and it is still the step people skip in favour of chasing a round Lighthouse score on the homepage.
If the numbers you are looking at came from a plugin heavy WordPress install, some of this is unreachable without changing how pages get delivered in the first place.
Related readingMoving a WordPress site to Next.js without losing your rankingsRelated readingWhy a headless CMS beats building on WordPress or WixResources
- web.dev: Core Web Vitals
Definitions and thresholds for all three metrics.
- web.dev: optimise Interaction to Next Paint
The clearest breakdown of what INP measures and how to reduce it.
- web.dev: optimise Largest Contentful Paint
The four LCP sub-parts, which is the right way to think about it.
- Chrome User Experience Report
The field data behind Search Console. Queryable through the API and BigQuery.
- web-vitals on GitHub
The measurement library. Next.js wraps it in useReportWebVitals.
- Next.js: optimising images
How the Image component handles sizing, formats and priority.