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.

LCP2.5s4.0sINP200ms500msCLS0.10.25GoodNeeds workPoor
Thresholds published by Google for the three Core Web Vitals. A page passes when the 75th percentile of real visits falls in the good band. Source: web.dev.

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.

SourceWhat it measuresUse it for
Lighthouse / PageSpeed labOne simulated load on one deviceFinding the cause after you know the symptom
Search Console Core Web VitalsReal visits, grouped by URL patternDeciding what to work on at all
CrUX dashboard or APIReal visits, 28 day rolling windowTracking whether a fix actually landed
web-vitals in your own analyticsReal visits, your own segmentationCatching regressions within days, not weeks
Start at the top row only after the second row has told you which metric is failing.

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.

app/blog/[slug]/page.tsxtypescript
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.

app/layout.tsxtypescript
<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.

app/lib/fonts.tstypescript
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.

app/blog/[slug]/page.tsxtypescript
// 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.

app/components/Comments.tsxtypescript
'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.

app/layout.tsxtypescript
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:

CauseWhat you seeFix
Image without dimensionsText jumps down when the image appearsAlways set width and height, or an aspect-ratio
Webfont swapText reflows slightly once the real font loadsMatch fallback metrics with adjustFontFallback
Injected banner or noticeWhole page shoves down after loadReserve the height, or position it out of flow
Ad or embed slotContent moves as each slot fillsGive every slot a fixed min-height
Late loading web componentA gap fills in after everything elseRender a placeholder of the same size
Every row is the same underlying problem: something occupies space that was not set aside for it.
app/components/Figure.tsxtypescript
// 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.

app/components/Vitals.tsxtypescript
'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.

scripts/vitals-p75.sqlsql
-- 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 Wix

Resources