Mastering Core Web Vitals in Next.js: Sub-100ms INP and Zero CLS
Eliminating layout shifts, font swapping flashes, and optimizing client interaction latency.
1. Eliminating Cumulative Layout Shift (CLS)
CLS measures unexpected movement of visible content — the score is a function of how much of the viewport shifted and how far it moved. It's almost always caused by content that renders after the initial paint and pushes everything below it: an image without reserved dimensions, a web font that swaps to a wider or taller fallback, an ad slot or embed that loads late, or a banner injected above existing content. Each of these is fixable, and fixing them is mechanical once you know which one is firing.
The image case is the most common and the easiest to eliminate. Browsers can't reserve space for an image until they know its aspect ratio, so any `<img>` without explicit `width` and `height` (or a CSS `aspect-ratio`) causes a shift the moment it loads. Next.js's `<Image>` component requires width and height (or `fill` with a sized parent) specifically to force this reservation, and it should be the only way images enter the page — a raw `<img>` tag reintroduces the exact bug the component exists to prevent.
Font swapping is the second-most common cause and the least obvious. `next/font` solves this by self-hosting fonts and generating a fallback font with matching metrics (`size-adjust`, `ascent-override`) at build time, so the browser's fallback font occupies the same box as the eventual web font — the text is legible from the first paint, in a system font, and doesn't reflow when the real font arrives, because the two are metrically compatible. Loading fonts from a CDN with a plain `<link>` tag reintroduces the classic flash-of-unstyled-text-then-reflow problem `next/font` exists to eliminate.
Dynamically injected content — cookie banners, promotional strips, late-loading ads — is the hardest category because the content is often legitimately unknown at render time. The fix isn't to avoid it, it's to reserve space: give the container a `min-height` sized to the eventual content, or position genuinely floating elements with `position: fixed` or `absolute` so they overlay rather than push. Anything that inserts itself into normal document flow above the fold after initial render is a CLS regression waiting to be measured.
2. Sub-100ms Interaction to Next Paint (INP)
INP replaced First Input Delay as the Core Web Vital for responsiveness because FID only measured the delay before an event handler started running — it said nothing about how long the handler itself took, or how long the browser took to paint the result. INP measures the full interaction: from the user's click or keystroke to the next frame the browser actually paints, capturing the worst (or close to worst) interaction on the page across its lifetime, not just the first one.
The dominant cause of a slow INP is a long task on the main thread — any synchronous JavaScript that runs for more than roughly 50ms and blocks the browser from responding to input or painting a frame. This includes expensive re-renders triggered by a state update, synchronous work inside an event handler (sorting a large array, running a regex over a big string, serializing a large object), and third-party scripts that hijack the main thread during their own execution.
The fix is to get non-essential work off the interaction's critical path. `startTransition` marks a state update as non-urgent, letting React interrupt it to keep the UI responsive to more urgent updates (like the next keystroke) instead of blocking on a potentially expensive re-render. For genuinely heavy computation — filtering a large dataset, parsing a big payload — moving the work to a Web Worker removes it from the main thread entirely, so it can't block paint or input handling no matter how long it takes.
A second, often-overlooked contributor to poor INP is hydration cost on route load: a page that ships a lot of client JavaScript has a window during hydration where event handlers are attached but the main thread is busy running React's reconciliation, so the very first interactions after navigation feel sluggish even though nothing is technically wrong. This is the App Router's server-components-by-default model paying off again — less client JavaScript to hydrate means a shorter window of unresponsiveness right after the page becomes visible.
3. Image Optimization and Next-Gen Formats
Images are still the single largest contributor to page weight on most sites, and format choice alone typically accounts for a large share of achievable savings. AVIF generally compresses smaller than WebP for equivalent visual quality, and both are substantially smaller than JPEG or PNG at matched quality; the practical decision is rarely which format to pick by hand but whether your pipeline serves the best format each browser supports automatically.
`next/image` handles this negotiation for you: it serves AVIF or WebP to browsers that support them and falls back to the original format otherwise, generates multiple resolutions and lets the browser pick via `srcset`, and lazy-loads any image below the fold by default so it doesn't compete for bandwidth with content the user can already see. None of this requires manual `<picture>` elements or a separate image CDN configuration — it's the default behavior of the component.
The two settings that matter most in practice are `priority` and `sizes`. `priority` should be set on exactly the images that are visible without scrolling on a typical viewport — usually the hero image or the largest above-the-fold image — because it removes lazy-loading and adds a preload hint, directly improving Largest Contentful Paint. `sizes` tells the browser how large the image will actually render at each breakpoint, and getting it wrong (or omitting it) causes the browser to download a larger image than it needs, which is a common silent source of wasted bandwidth on responsive layouts.
4. Continuous Core Web Vitals Monitoring
Lab tools like Lighthouse are useful for catching regressions in CI, but they measure a single synthetic run on a fixed device and network profile — they don't tell you what your actual users experience on their actual devices and connections, which vary enormously. Field data, collected from real user sessions via the `web-vitals` library and reported to an analytics endpoint, is what Google's Core Web Vitals scoring actually uses, and it's the only data source that reflects reality: a slow rural connection, a five-year-old Android phone, a flaky ISP.
Setting this up means importing `onCLS`, `onINP`, and `onLCP` from `web-vitals` in a small client component mounted once near the root layout, and sending each metric to your analytics backend as it's reported — these callbacks fire asynchronously as the relevant events occur during the page's lifetime, not all at once on load. The data is inherently noisy at the individual-session level, so the useful signal is the p75 (75th percentile) across a rolling window, which is also the percentile Google's Core Web Vitals report uses to classify a page as good, needs improvement, or poor.
Once real-user data is flowing, the highest-value use of it is segmenting by page template, device type, and connection speed rather than looking at a single site-wide average — a homepage and a checkout flow have completely different vitals profiles, and a single blended number hides which one is actually failing. Tracking p75 INP per route over time also turns a subjective 'the site feels slower lately' complaint into a measurable regression you can bisect against recent deploys.
5. Synthesis: A Checklist for Every New Page
Core Web Vitals aren't three unrelated numbers to chase independently — they're symptoms of a small set of root causes, and fixing the root causes moves all three at once. Unreserved space (images, fonts, dynamic content) drives CLS. Long-running main-thread JavaScript drives INP. Unoptimized or oversized assets on the critical rendering path drive LCP. Most of the fixes are structural, applied once at the component or config level, rather than page-by-page tuning.
A concrete checklist for shipping a new page: every image goes through `next/image` with explicit dimensions and `priority` set only on the above-the-fold hero; every font goes through `next/font` rather than a CDN `<link>`; any interaction handler doing non-trivial work wraps its state update in `startTransition`, or moves the heavy part to a Web Worker; any late-loading content (banners, embeds, ads) gets a reserved `min-height` container before it renders; and `web-vitals` field reporting is wired up from day one so regressions show up in real data, not just in a Lighthouse run three months after the fact.

