Back/Module A-12 Core Web Vitals Engineering
Module A-12·25 min read

LCP critical path, CLS root causes in Next.js, INP and long task elimination, useReportWebVitals instrumentation, webVitalsAttribution for element-level diagnosis, and when synthetic Lighthouse disagrees with your RUM data.

A-12 — Core Web Vitals Engineering

Who this is for: Architects who need to move the needle on real user metrics — not just Lighthouse scores on fast hardware, but actual CrUX data from users on 4G with mid-range phones. This module is about the specific Next.js decisions that affect LCP, INP, and CLS, and the debugging techniques that identify which decisions are hurting you.


Why Lighthouse Scores Lie

Every engineer has had the experience of a 97 Lighthouse score in development and a poor Core Web Vitals report in production. The disconnect has a mechanical explanation.

Lighthouse simulates a single device (Moto G4 equivalent, slow 4G) in a controlled environment. It runs the same page several times and averages the result. It doesn't simulate real-world conditions: CDN edge distance, ISP variability, browser caching state, time-of-day traffic.

CrUX (Chrome User Experience Report) — the data Google actually uses for ranking — is the 75th percentile of real user measurements over 28 days. A user on a slow connection in a distant region counts. A user with 40 browser tabs open counts.

The gap between Lighthouse and CrUX is almost always:

  • The test environment has a warm CDN cache. Real users hit cold caches.
  • The test device is controlled. Real users have high CPU load from other tabs.
  • The test network is simulated. Real networks are variable.

Optimise for CrUX, use Lighthouse as a quick check only.


LCP — Largest Contentful Paint

LCP measures when the largest visible element in the viewport is rendered. For most pages, this is a hero image, a heading, or a large above-the-fold text block.

The target: LCP under 2.5 seconds at the 75th percentile.

The image LCP pattern:

tsx
import Image from 'next/image'; export default function HeroSection() { return ( <Image src="/hero.jpg" alt="Hero image" width={1440} height={600} priority // ← preloads this image — critical for LCP sizes="100vw" quality={85} /> ); }

priority on the LCP image is the single highest-impact LCP optimisation available in Next.js. It adds a <link rel="preload"> in the <head> for this image, telling the browser to fetch it before it would otherwise discover it in the HTML. The difference is typically 300-800ms of LCP improvement.

Only use priority on the actual LCP element. Using it on multiple images defeats the purpose and wastes bandwidth.

sizes attribute for responsive images:

tsx
<Image src="/product.jpg" alt="Product" width={800} height={600} sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 800px" />

Without sizes, Next.js generates multiple image sizes but the browser has to download the full-resolution image to determine which size to use. With sizes, the browser can pick the right image before downloading it.

The sizes value tells the browser: "on mobile this image will be 100% of the viewport width; on tablet it'll be 50%; on desktop it'll be 800px." Next.js generates the correct srcset based on this.


LCP — Text-Based LCP

When the LCP element is text (heading, hero copy), the relevant optimisations shift to font loading:

tsx
// app/layout.tsx import { Inter } from 'next/font/google'; const inter = Inter({ subsets: ['latin'], display: 'swap', // show fallback font until custom font loads preload: true, // preload the font fallback: ['system-ui', 'arial'], // reasonable fallback that matches Inter's metrics });

display: 'swap' prevents invisible text during font load (FOIT — Flash of Invisible Text). Text renders in the fallback font immediately, then swaps to the custom font when it loads.

The remaining problem: the layout shift when the font swaps. The size-adjust CSS property (automatically applied by next/font when possible) adjusts the fallback font's metrics to match the custom font, minimising visible layout shift.

tsx
// Check if next/font generated size-adjust // In browser DevTools: inspect the @font-face rule in the injected <style> // Look for size-adjust, ascent-override, descent-override, line-gap-override

These four properties, automatically set by next/font, make the fallback font occupy the same space as the custom font. When the swap happens, there's no layout shift.


INP — Interaction to Next Paint

INP (Interaction to Next Paint) replaced FID as the interaction metric in March 2024. Where FID measured the delay before the browser processed an input, INP measures the full duration from input to visual update — including JavaScript execution time.

Target: INP under 200ms at the 75th percentile.

INP is primarily a JavaScript execution problem. The two main causes:

Long Tasks blocking the main thread:

tsx
// ❌ Synchronous work on the main thread during a click handler function handleFilter(category: string) { const filtered = products.filter(p => { // Expensive computation — runs synchronously on main thread return expensiveMatch(p, category); }); setFilteredProducts(filtered); } // ✅ Yield to the browser between work units function handleFilter(category: string) { startTransition(() => { // React marks this as a non-urgent update // The browser can handle input events between renders setActiveCategory(category); }); }

startTransition tells React this state update is not urgent. React can interrupt the render to process user input, preventing the main thread from being blocked. The filter render might take longer, but the user's next click is still responsive.

Hydration blocking INP on page load:

tsx
// ❌ Large Client Component hydrates all at once import HeavyInteractiveSection from './HeavyInteractiveSection'; // ✅ Lazy hydration — defer until user interaction import dynamic from 'next/dynamic'; const HeavyInteractiveSection = dynamic( () => import('./HeavyInteractiveSection'), { ssr: true, loading: () => <SectionSkeleton /> } );

By deferring the download and hydration of non-critical interactive sections, the initial hydration is smaller and faster. The page becomes interactive for the visible, important sections first.


CLS — Cumulative Layout Shift

CLS measures unexpected layout shifts. Target: CLS under 0.1 at the 75th percentile. Every element that shifts during page load contributes to the score.

The three main CLS sources in Next.js apps:

1. Images without dimensions:

tsx
// ❌ No dimensions — browser doesn't reserve space, image causes shift when loaded <img src="/hero.jpg" alt="Hero" /> // ✅ next/image reserves exact space based on width/height props <Image src="/hero.jpg" alt="Hero" width={1440} height={600} /> // ✅ Or fill mode within a sized container <div style={{ position: 'relative', height: '400px' }}> <Image src="/hero.jpg" alt="Hero" fill style={{ objectFit: 'cover' }} /> </div>

2. Dynamic content insertion above existing content:

tsx
// ❌ Banner appears after page load, pushes content down function Page() { const [showBanner, setShowBanner] = useState(false); useEffect(() => { setShowBanner(hasCookieBanner()); }, []); return ( <> {showBanner && <CookieBanner />} {/* ← appears after hydration, causes shift */} <main>...</main> </> ); } // ✅ Reserve space for the banner regardless function Page() { const [showBanner, setShowBanner] = useState(false); useEffect(() => { setShowBanner(hasCookieBanner()); }, []); return ( <> <div style={{ minHeight: showBanner ? 'auto' : '48px' }}> {/* reserved height */} {showBanner && <CookieBanner />} </div> <main>...</main> </> ); }

3. Web font causing text reflow:

Covered in the LCP section — next/font with size-adjust overrides eliminates this source of CLS.


Measuring in Production

@vercel/speed-insights reports Core Web Vitals from real users back to the Vercel dashboard:

tsx
// app/layout.tsx import { SpeedInsights } from '@vercel/speed-insights/next'; export default function RootLayout({ children }: { children: React.ReactNode }) { return ( <html> <body> {children} <SpeedInsights /> </body> </html> ); }

For non-Vercel deployments, the Web Vitals API is directly usable:

ts
// instrumentation-client.ts import { onCLS, onINP, onLCP } from 'web-vitals'; export function register() { onCLS(metric => sendToAnalytics(metric)); onINP(metric => sendToAnalytics(metric)); onLCP(metric => sendToAnalytics(metric)); } function sendToAnalytics(metric: Metric) { // Send to your analytics service fetch('/api/vitals', { method: 'POST', body: JSON.stringify(metric), keepalive: true, // fires even if page is unloading }); }

instrumentation-client.ts with register() is the correct place for this — it runs once on browser boot before any page component mounts.


Next.js 15's <Form> component extends the native HTML form with prefetching behaviour:

tsx
import Form from 'next/form'; export function SearchBar() { return ( <Form action="/search"> <input name="q" placeholder="Search..." /> <button type="submit">Search</button> </Form> ); }

When the user focuses the search input, <Form> prefetches the /search page's loading skeleton. When they submit, the skeleton is already in the Router Cache — the search results page begins with the skeleton immediately rather than a blank page. For perceived performance, this is significant: the user sees instant feedback even before the search results arrive.


Where We Go From Here

A-13 addresses the security architecture of a Next.js application — CSP, CSRF beyond what Server Actions provide automatically, secrets management, and the attack surface specific to server-side rendering. After optimising for user experience in A-12, A-13 ensures the application is hardened against real-world attacks.

Discussion