Core Web Vitals: Raising Lighthouse Scores on a Next.js Site
Core Web Vitals are the three metrics Google uses to measure page experience, and they factor into search ranking. But the real benefit isn't SEO: improving them makes the site genuinely more pleasant to use.
This post covers what each metric measures and the fixes that actually move the needle in Next.js.
The three metrics
LCP (Largest Contentful Paint) — how long until the largest content element appears. Usually the hero image or main heading. Target: under 2.5 seconds.
CLS (Cumulative Layout Shift) — how much content jumps around while loading. Content shifting just as a user reaches for a button wrecks this metric. Target: under 0.1.
INP (Interaction to Next Paint) — how quickly the interface responds to a click. Heavy JavaScript occupying the main thread makes this worse. Target: under 200 ms.
Why does font loading delay LCP?
The LCP element is often text. If text stays invisible until its font downloads, LCP is delayed. In a Lighthouse report you'll see this as "element render delay" — when most of LCP is spent there, the bottleneck isn't the network, it's the font.
The fix is display: "swap":
import { Geist } from "next/font/google";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
display: "swap", // show fallback text until the font arrives
});With swap, text paints immediately in a system font and swaps once the real one loads. Users never stare at a blank page.
Second point: don't load fonts you don't use. A common find is a monospace font that's declared and never referenced. Checking is easy:
grep -rn "font-mono" src/If nothing comes back, delete that declaration — every font is another request and another delay.
Why do images cause CLS?
An image without declared dimensions pushes content down when it loads. Next.js's <Image> prevents this because it knows the size and reserves the space:
import Image from "next/image";
<div className="relative aspect-[1200/630] w-full overflow-hidden rounded-2xl">
<Image
src={coverUrl}
alt=""
fill
className="object-cover"
sizes="(max-width: 768px) 100vw, 768px"
priority
/>
</div>Four details here:
aspect-[1200/630] — space is reserved before the image arrives, so nothing shifts.
sizes — tells the browser "full width on mobile, at most 768px on desktop," so it doesn't download an oversized file.
priority — only for images above the fold. Marking every image priority means none of them are.
alt="" — correct when an image is purely decorative; screen readers skip it. If it carries information, write a real description.
If you serve images from a remote host, you must declare it:
// next.config.ts
images: {
remotePatterns: [
{
protocol: "https",
hostname: "*.example-cdn.com",
pathname: "/public/**",
},
],
}Constraining pathname matters — leaving a host fully open can let others use your optimisation endpoint.
What most often lowers a Lighthouse accessibility score?
The most commonly failed item in Lighthouse's accessibility audit is colour contrast. Your brand colour may look fine on white and still fall short of the WCAG AA threshold of 4.5:1.
Measuring it is simple:
function luminance(hex) {
const c = hex.replace("#", "").match(/../g).map((x) => {
const v = parseInt(x, 16) / 255;
return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
});
return 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2];
}
function contrastRatio(a, b) {
const [l1, l2] = [luminance(a), luminance(b)].sort((x, y) => y - x);
return (l1 + 0.05) / (l2 + 0.05);
}
console.log(contrastRatio("#0d9488", "#ffffff")); // 3.74 → fails
console.log(contrastRatio("#0d7d72", "#ffffff")); // 5.01 → passesDarkening the colour slightly usually clears the threshold without breaking brand identity. If colours live in CSS variables, one line fixes the whole site:
:root {
--accent: #0d7d72; /* 5.01:1 on white */
}Check the dark theme separately — a colour that passes on white behaves differently on a dark ground.
What's the difference between lab scores and field data (CrUX)?
PageSpeed Insights shows two different datasets, and conflating them is common:
Lab data (Lighthouse) — a single measurement in a controlled environment with artificially throttled CPU and network. Repeatable and comparable, but not representative of real users.
Field data (CrUX) — 28 days of data collected from actual Chrome users. This is what Google uses for ranking.
A new site has no field data yet; seeing "No data" is normal. Rather than chasing 100 in the lab, wait for real-user data and look at that.
A typical sticking point in lab scores is Speed Index. It includes framework hydration cost and, on a React-based site, stops improving past a point. Cutting functionality for the last few points is usually a bad trade.
Misleading warnings
Two things in a Lighthouse report not to panic about:
Prefetch timeouts. Next.js downloads content for visible links in advance. On Lighthouse's artificially throttled network those requests time out and appear as console errors. They don't occur in real use, and the page works fine even when they fail.
Transient resource errors. Messages like "robots.txt fetch failed — timed out" sometimes come from a hiccup in Google's own test infrastructure. Verify directly:
curl -s -o /dev/null -w "%{http_code} %{time_total}s\n" https://example.com/robots.txtIf that returns 200 in reasonable time, the problem isn't yours — rerun the test.
Measure in order
Record the baseline before you change anything:
npx lighthouse https://example.com \
--only-categories=performance,accessibility,best-practices,seo \
--form-factor=mobile --screenEmulation.mobile \
--output=json --output-path=./before.jsonMake one change, measure again, compare. Change five things at once and you won't know which helped — some may even cancel each other out.
Summary
The three interventions that make the most difference in practice: add display: swap to fonts, give images a fixed aspect ratio, and remove resources you don't use. These are a few lines each and act directly on LCP and CLS.
For everything else, measure, change one thing at a time, and treat the lab score as an indicator rather than a target.