Core Web Vitals are not a vanity score. They are a proxy for whether a real person on a real device feels your Next.js app as fast, stable, and responsive. The metrics are simple to name and surprisingly easy to get wrong, especially once you layer in the App Router, React Server Components, and a font or two. This is a practical field guide to diagnosing and fixing LCP, INP, and CLS in production Next.js, with the budgets and concrete changes we actually ship for clients.
The three metrics, and what they really measure
Core Web Vitals reduce to three numbers, each measured at the 75th percentile of your real users:
- LCP (Largest Contentful Paint) — when the biggest above-the-fold element finishes rendering. Usually a hero image, a heading, or a video poster. Good is under 2.5s.
- INP (Interaction to Next Paint) — the worst-ish latency between a user input (tap, click, keypress) and the next frame the browser paints. It replaced FID in 2024 and is far stricter. Good is under 200ms.
- CLS (Cumulative Layout Shift) — how much visible content jumps around without the user causing it. Good is under 0.1.
The trap is optimizing for lab tools (Lighthouse, a single Vercel Speed Insights run) instead of field data. Lab tools run on a clean machine with a throttled-but-predictable network. Your users have battery-saver throttling, third-party scripts, and a tab that has been alive for forty minutes. Always confirm against field data: Chrome's CrUX report, the web-vitals library streaming to your analytics, or Vercel Speed Insights' real-user numbers.
Instrument first: measure RUM before you guess
Before touching code, wire up real-user monitoring. Next.js exposes a useReportWebVitals hook that hands you each metric as it finalizes. Send it somewhere you can segment by route and device.
// app/_components/web-vitals.tsx
'use client';
import { useReportWebVitals } from 'next/web-vitals';
export function WebVitals() {
useReportWebVitals((metric) => {
const body = JSON.stringify({
name: metric.name, // 'LCP' | 'INP' | 'CLS' | ...
value: metric.value,
rating: metric.rating, // 'good' | 'needs-improvement' | 'poor'
id: metric.id,
path: window.location.pathname,
});
// sendBeacon survives page unload; fetch/keepalive is the fallback
navigator.sendBeacon?.('/api/vitals', body) ??
fetch('/api/vitals', { body, method: 'POST', keepalive: true });
});
return null;
}Mount <WebVitals /> once in your root layout. The point is segmentation: a 2.4s LCP averaged across the site can hide a 6s LCP on your highest-traffic landing page. Optimize the page that moves the metric, not the average.
Fixing LCP: it is almost always the image or the request chain
LCP failures cluster into a few causes. Work them in order.
Find and prioritize the LCP element
Open DevTools Performance, record a load, and read the "LCP" marker. It tells you the exact element. If it is an image, the fix is usually priority plus correct sizing.
import Image from 'next/image';
<Image
src="/hero.jpg"
alt="Restaurant dining room at dusk"
width={1280}
height={720}
priority // preloads, skips lazy-loading
sizes="(max-width: 768px) 100vw, 1280px"
fetchPriority="high"
/>priority emits a <link rel="preload"> so the browser fetches the hero before it discovers it in the DOM. Without it, the image waits behind CSS and JS in the request chain. The sizes attribute is not optional: get it wrong and next/image serves a 1280px file to a 390px phone, doubling your LCP on mobile.
Shorten the request chain
LCP is gated by the slowest dependency in the critical path. Common Next.js offenders:
- Render-blocking data fetches in a Server Component that the LCP element depends on. If the hero text comes from a slow CMS call, the whole paint waits. Stream non-critical content with
<Suspense>and keep the LCP element on a fast path. - A client component wrapping the hero that needs to hydrate before it shows. Keep the hero as a Server Component whenever possible.
- Origin latency. A cold serverless function in a region far from the user can add 300-800ms. Use static generation (
generateStaticParams, ISR) for landing pages so the HTML is served from the edge cache, not computed per request.
Self-host fonts and preconnect to image hosts
If your LCP element is a text block, the font load gates it. See the font section below. If it is a remote image, add preconnect to that host in the document head so DNS, TLS, and TCP are warm before the fetch begins.
Fixing INP: the metric that punishes heavy client JavaScript
INP is where modern React apps quietly fail. Every click runs your event handlers, then React's reconciliation, then layout and paint — all on the main thread. If any of that takes too long, the next frame is late and INP climbs.
Reduce hydration cost with the right component boundary
The single biggest INP lever in the App Router is shipping less client JavaScript. Server Components render to HTML and ship zero JS. The mistake is putting 'use client' too high in the tree and dragging an entire subtree into the client bundle.
- Push
'use client'as far down as possible — to the actual interactive leaf (a button, a toggle), not the page. - Pass Server Components into Client Components as
childrenprops so the static parts stay server-rendered. - Audit your bundle with
@next/bundle-analyzer. A 400KB client bundle on a mid-tier Android phone is seconds of parse-and-execute before anything is interactive.
Break up long tasks
A "long task" is any main-thread block over 50ms. They are the raw material of bad INP. Tactics:
- Defer non-urgent state updates with React's
useTransitionso the input paint is not blocked by an expensive re-render. - Yield to the browser in long loops.
await scheduler.yield()(or asetTimeout(0)fallback) lets the browser paint the response to the user's tap before continuing work. - Memoize honestly.
useMemo/React.memoonly help if the expensive thing actually re-runs; profile before sprinkling them.
'use client';
import { useState, useTransition } from 'react';
export function Filter({ items }: { items: Item[] }) {
const [query, setQuery] = useState('');
const [results, setResults] = useState(items);
const [, startTransition] = useTransition();
return (
<input
onChange={(e) => {
setQuery(e.target.value); // urgent: keeps input responsive
startTransition(() => { // non-urgent: heavy filter
setResults(items.filter((i) => i.name.includes(e.target.value)));
});
}}
value={query}
/>
);
}Tame third-party scripts
Analytics, chat widgets, and tag managers are frequent INP killers because they hijack the main thread. Load them with next/script and strategy="lazyOnload" or worker (Partytown) so they do not compete with user interactions during the critical early window.
Fixing CLS: reserve space before content arrives
CLS is the most fixable metric because the cause is almost always missing dimensions.
- Always set `width` and `height` (or use
fillwith a sized parent) onnext/image. The aspect ratio reserves space so the image does not shove text down when it loads. - Reserve space for ads, embeds, and dynamic banners with a fixed
min-heightcontainer. A consent banner that appears after hydration and pushes the page is a classic CLS spike. - Avoid inserting content above existing content. Skeletons help only if they match the final layout's dimensions.
- Use `font-display: optional` or `swap` with a metric-matched fallback so a font swap does not reflow the page.
Font loading: the cross-cutting fix for LCP and CLS
Fonts touch both LCP (text paint) and CLS (reflow on swap). next/font solves most of it by self-hosting and inlining the font CSS, eliminating a render-blocking round trip to Google Fonts.
// app/layout.tsx
import { Inter } from 'next/font/google';
const inter = Inter({
subsets: ['latin'],
display: 'swap',
adjustFontFallback: true, // generates a metric-compatible fallback => ~0 CLS
variable: '--font-inter',
});
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" className={inter.variable}>
<body>{children}</body>
</html>
);
}adjustFontFallback is the quiet hero: it computes a fallback font sized to match the web font's metrics, so when the real font swaps in, line heights and widths barely move. Load only the weights and subsets you use — every extra weight is bytes on the critical path.
RSC payload and over-fetching
React Server Components stream a serialized payload (the "RSC payload") to the client for hydration and navigation. It is usually invisible, but it can bloat LCP and INP when you over-fetch.
- Do not pass giant objects from Server to Client Components. Everything you hand a Client Component gets serialized into the RSC payload and shipped. Select only the fields the client needs.
- Prefetch deliberately. The App Router prefetches links in the viewport. On a page with hundreds of links, that is a lot of payload. Set
prefetch={false}on low-intent links. - Cache aggressively for static routes. ISR and
fetchcaching keep the RSC payload coming from cache rather than recomputing per request, which directly helps LCP for SSR pages.
Real budgets we ship with
Targets are useless without enforcement. These are the field (75th-percentile) budgets we hold client projects to, plus the lab guardrails we put in CI:
- LCP < 2.5s field / < 2.0s lab on a throttled mobile profile.
- INP < 200ms field; investigate any route over 150ms.
- CLS < 0.1 field; we treat anything over 0.05 as a regression worth a look.
- First-load JS per route under ~130KB gzipped. This is the lever that protects INP on cheap phones.
- Total blocking time in Lighthouse under 200ms.
Enforce them in CI with Lighthouse CI assertions so a PR that ships a 90KB analytics dependency fails the check instead of quietly degrading production. Pair that with the RUM stream so you catch regressions that only appear on real devices.
Frequently Asked Questions
What is a good INP score for a Next.js app?
Aim for INP under 200ms at the 75th percentile of real users; 200-500ms needs improvement, and over 500ms is poor. In Next.js, the main lever is shipping less client JavaScript by keeping 'use client' on interactive leaves, deferring heavy updates with useTransition, and loading third-party scripts with a lazy strategy.
Does next/image automatically fix CLS?
Mostly, but only if you give it dimensions. When you set width and height, or use fill inside a sized parent, next/image reserves the correct aspect-ratio space so loading the image does not shift surrounding content. Omitting dimensions or using an unsized fill parent reintroduces layout shift, which is the most common avoidable CLS cause.
Why is my LCP slow even with a fast server?
A fast origin does not help if the LCP element is late in the request chain. Common causes: the hero image lacks priority so it is not preloaded, an incorrect sizes attribute serves an oversized file, a slow data fetch blocks the paint, or a client component must hydrate before the hero renders. Fix the critical path, not just server time.
Should I use Server Components to improve Core Web Vitals?
Yes, deliberately. Server Components ship zero JavaScript, which directly lowers hydration cost and improves INP on low-end devices. Keep static, content-heavy parts as Server Components and isolate interactivity in small Client Component leaves. Avoid passing large objects across the boundary, since everything sent to a Client Component is serialized into the RSC payload.
Lighthouse says my site is fast but CrUX disagrees. Why?
Lighthouse is a lab test on a clean, predictable machine; CrUX and other field data reflect real users on varied devices, networks, and long-lived tabs with third-party scripts. Trust field data for Core Web Vitals decisions and use Lighthouse only as a fast local guardrail. Segment field data by route and device to find the real regressions.
Working with CodeAustral
We build and tune Next.js platforms for clients worldwide, and performance work like this is part of how we ship, not an afterthought bolted on at the end. If your app is missing its Core Web Vitals targets or you want a performance budget enforced in CI before the next launch, send us a short brief at codeaustral.com/contact and we will tell you where the real wins are.

