Cutting Next.js LCP: A Field Guide to Core Web Vitals
Lab scores are a smoke test. The numbers that affect your rankings come from real devices on real networks, and they respond to a different set of fixes.
Cenedy Udoy Palma
Backend Developer & AI Engineer
There is a specific kind of frustration in watching Lighthouse hand you a green 98 while Search Console reports that your Largest Contentful Paint is failing for a third of visitors. Both are telling the truth. They are measuring different things.
Lighthouse runs a simulated throttle on your machine with a warm cache. Field data comes from actual Chrome users — mid-range Android phones on congested mobile networks, cold cache, battery saver on. The gap between those two is where most performance work should happen.
LCP is almost always one image or one font
Largest Contentful Paint measures when the biggest above-the-fold element finishes rendering. On a portfolio or content site that is nearly always a hero image or a heading in a web font. Everything else is noise.
The single most common mistake is lazy-loading the hero. `next/image` defaults to lazy, which is right for everything below the fold and actively harmful for the LCP element — the browser will not even start the request until layout runs.
import Image from "next/image";
// The LCP element. priority preloads it in the document head.
<Image
src="/images/hero.png"
alt="..."
width={720}
height={720}
priority
sizes="(max-width: 768px) 100vw, 720px"
/>The `sizes` attribute matters more than people expect. Without it, the browser assumes the image occupies the full viewport width and downloads the largest candidate — so your phone user fetches a 1400px asset to paint it at 380px. Getting `sizes` right routinely cuts hero payload by 70% on mobile with no visual difference.
Fonts: the invisible LCP killer
If your LCP element is text, LCP cannot complete until the font it uses is loaded and applied. A render-blocking font request on a slow connection can hold LCP for over a second while the pixels are technically ready to paint.
`next/font` solves the worst of this by self-hosting the file at build time — no DNS lookup, no connection to a third-party origin, and it injects `font-display: swap` automatically. But two things are still worth checking.
import { Inter } from "next/font/google";
const inter = Inter({
variable: "--font-sans",
subsets: ["latin"], // never load subsets you don't render
display: "swap",
// Trims the fallback-to-webfont layout shift to near zero.
adjustFontFallback: true,
});Subsetting is the big win — pulling in Cyrillic and Greek glyphs for an English-only site can double the font payload. And if you load more than two families or more than three weights, audit whether every one is genuinely used. Most designs need two.
INP: the metric that replaced FID, and why it is harsher
Interaction to Next Paint measures the full latency from a user's tap to the next frame — input delay, processing, and rendering. First Input Delay only measured the first part, which is why so many sites that passed FID comfortably now fail INP.
In App Router projects the usual culprit is shipping too much JavaScript to the client. Every `"use client"` boundary pulls that component and its imports into the browser bundle, and it is easy to mark a whole page as client because one button needs state.
// Before: the entire page is a client component for one interaction.
"use client";
export default function Page() {
const [open, setOpen] = useState(false);
return (
<article>
<LongStaticContent /> {/* shipped to the browser for no reason */}
<button onClick={() => setOpen(!open)}>Toggle</button>
</article>
);
}// After: the page stays a server component; only the widget is client.
export default function Page() {
return (
<article>
<LongStaticContent /> {/* zero JS */}
<ToggleWidget /> {/* the only "use client" file */}
</article>
);
}Push the boundary as far down the tree as it will go. The mental model that helps: `"use client"` is not a per-file setting, it is a cut in the component tree, and everything below the cut goes to the browser.
The second INP culprit is animation libraries running on scroll. A `whileInView` on every section means dozens of observers plus a layout-affecting animation each. Prefer CSS transforms and opacity — those run on the compositor and never block the main thread — and reserve JavaScript animation for interactions that genuinely need it.
CLS: reserve space for anything that arrives late
Cumulative Layout Shift punishes content that moves after paint. The causes are predictable: images without dimensions, web fonts swapping at a different metric, and injected content like ad slots or cookie banners.
- Always give images explicit width and height, or a wrapper with a fixed aspect ratio. Reserved space cannot shift.
- Give ad and embed containers a min-height matching the unit size, so the slot exists before the iframe fills it.
- Never insert banners or notices above existing content after hydration — overlay them, or reserve their height in the initial HTML.
- Use `adjustFontFallback` so the fallback font's metrics approximate the webfont's.
Load third-party scripts on the right strategy
Analytics, tag managers and ad scripts are pure main-thread cost with no contribution to your content. `next/script` exists to keep them out of the critical path:
import Script from "next/script";
// afterInteractive: runs once the page is usable. Right for analytics and ads.
<Script src="https://example.com/analytics.js" strategy="afterInteractive" />
// lazyOnload: waits for browser idle. Right for chat widgets and social embeds.
<Script src="https://example.com/widget.js" strategy="lazyOnload" />A raw `<script async>` tag placed in the document head competes with your own critical resources for bandwidth and parse time. Moving those to `afterInteractive` is often worth a couple of hundred milliseconds on mobile for a one-line change.
Measure the field, not the lab
Lab tools are for finding regressions before you ship. Field data tells you what your users experienced. Track both, and trust the field.
- Search Console's Core Web Vitals report — grouped by URL pattern, sourced from real Chrome traffic. This is what affects ranking.
- A real-user monitoring hook via `useReportWebVitals`, so you see p75 continuously rather than every 28 days.
- Lighthouse CI in your pipeline as a regression gate, not as a target to optimise.
Optimise against p75, not the average. Core Web Vitals thresholds are evaluated at the 75th percentile, which means a fast experience for three quarters of visits. Averages hide exactly the slow tail that determines whether you pass.
The practical order of operations on most sites: fix the LCP image first, then cut client JavaScript, then reserve space for late-arriving content, then move third-party scripts off the critical path. That sequence has consistently produced the largest improvement for the least effort.