Core Web Vitals in Next.js: What Actually Moves the Numbers
LCP, INP and CLS have specific causes in a Next.js application. This is the ordered list we work through on performance engagements, with the changes that pay and the ones that do not.
A Lighthouse score is a lab measurement of one page load on one simulated device. Field data is what your visitors actually experienced, and it is what Google uses. Start there, always:
# p75 field data for an origin, from the Chrome UX Report
curl -s "https://chromeuxreport.googleapis.com/v1/records:queryRecord?key=$CRUX_KEY" \
-H 'Content-Type: application/json' \
-d '{"origin":"https://example.com","formFactor":"PHONE"}'If your lab score is 95 and your field LCP is 3.8s, the lab is lying to you about something — usually a font, a third-party script, or a real-world network your simulated one is not modelling.
LCP: it is almost always the image or the font
Largest Contentful Paint measures when the biggest above-the-fold element finishes painting. In a Next.js application, four causes cover the large majority of failures.
1. The hero image is not prioritised
import Image from 'next/image';
<Image
src="/hero.jpg"
alt=""
width={1200}
height={630}
priority // preloads it; without this it waits in the lazy queue
sizes="100vw"
quality={80}
/>Without priority, your LCP element sits behind every other resource in the
browser's queue. This is a one-line change that routinely takes half a second
off mobile LCP.
Equally: priority on more than one or two images is the same mistake in
reverse — everything preloaded means nothing prioritised.
2. The route is dynamic when it did not need to be
A statically rendered route serves HTML from the nearest edge node. A dynamic one runs your component tree and your database first. If time to first byte is above 600ms, no amount of image optimisation will rescue LCP — the browser has not received anything to paint yet.
This is the connection between rendering strategy and Core Web Vitals that most performance checklists miss entirely.
3. Fonts block the paint
import { Inter } from 'next/font/google';
const inter = Inter({
subsets: ['latin'],
display: 'swap', // paint immediately with the fallback
preload: true,
adjustFontFallback: true, // reduces the shift when the real font arrives
});next/font self-hosts the file, which removes a DNS lookup, a TLS handshake
and a round trip to a third-party origin from your critical path. If you are
still loading fonts from a <link> to a font CDN, that is a free improvement
sitting on the table.
4. A third-party script is in the head
Tag managers, chat widgets, consent banners, session recorders. Each one is a blocking request on the critical path unless it is explicitly deferred:
import Script from 'next/script';
<Script src="https://example.com/widget.js" strategy="lazyOnload" />In audits, the single largest LCP contributor is a third-party script about a third of the time. It is worth measuring before you spend a sprint on your own code.
INP: the cost of hydration
Interaction to Next Paint replaced First Input Delay because FID measured the wrong thing — it timed only the delay before the handler started, not how long the visitor waited to see a result.
INP problems in Next.js are bundle problems. The causes, in the order we find them:
- A client boundary too high in the tree. Covered in depth in our article
on where
use clientactually costs you. - A heavy dependency imported at module scope. Charting libraries, date
libraries, editors. Load them with
next/dynamicandssr: falsewhen the component is genuinely below the fold. - Expensive work in an event handler. Filtering ten thousand rows in an
onChange. Move it to the server, or debounce and virtualise. - Too many hydration roots. A hundred independently interactive cards on a listing page is a hundred hydration units competing for the main thread.
import dynamic from 'next/dynamic';
const Chart = dynamic(() => import('@/components/Chart'), {
ssr: false,
loading: () => <ChartSkeleton />,
});CLS: reserve the space
Cumulative Layout Shift is the easiest of the three to fix and the most embarrassing to ship.
- Always give images explicit
widthandheight, or wrap them in a container with a fixedaspect-ratio.next/imageenforces this, which is one of the better reasons to use it. - Reserve space for anything that arrives late — ad slots, embeds, consent banners. A skeleton with the final element's dimensions costs nothing and removes the shift entirely.
- Use
font-display: swaptogether withadjustFontFallbackso the fallback metrics approximate the real font. - Never inject a banner above existing content after load. Overlay it, or reserve the space in the initial HTML.
Defending the result
Every fix above decays. Someone adds a dependency, someone adds a script, someone moves a boundary. Without an enforcement mechanism you will do this work again next year.
# .github/workflows/performance.yml
- name: Lighthouse CI
uses: treosh/lighthouse-ci-action@v12
with:
urls: |
https://staging.example.com/
https://staging.example.com/pricing
budgetPath: ./lighthouse-budget.json
uploadArtifacts: true[
{
"path": "/*",
"resourceSizes": [
{ "resourceType": "script", "budget": 180 },
{ "resourceType": "total", "budget": 600 }
],
"timings": [{ "metric": "interactive", "budget": 3000 }]
}
]A pull request that pushes the JavaScript budget past 180KB now fails a check instead of quietly shipping. That budget file is the actual deliverable of a performance engagement. The improved score is just what happens on the way to having one.
The order we work in
- Get field data. Segment by device class and route template.
- Fix rendering strategy — a dynamic route that should be static outranks everything else on this list.
- Fix the LCP element: priority, dimensions, format.
- Move fonts to
next/font, third-party scripts off the critical path. - Pull the client boundary down; code-split what is left.
- Reserve space for everything that arrives late.
- Put a budget in CI so none of the above can regress.
Steps two and three are usually most of the win. The rest is keeping it.
