Skip to main content
HYD --:--Let's talk

Fixing Core Web Vitals in React and Next.js

A practical guide to Core Web Vitals in Next.js: measure LCP, INP and CLS from real users, load the hero image correctly, cut hydration and stop layout shifts.

On this page
  1. The three metrics and what "good" means
  2. Field data vs lab data: why your scores disagree
  3. Measuring Core Web Vitals in Next.js from real users
  4. Fixing LCP
  5. Find the LCP element first
  6. Server-render it so the browser can find it
  7. Load the hero image with the Next.js 16 API
  8. Load fonts with next/font
  9. Don't hide the LCP element
  10. Fixing INP
  11. Reduce hydration cost with smaller client islands
  12. Keep urgent updates urgent
  13. Break up long tasks with scheduler.yield()
  14. Fixing CLS
  15. Give every image and embed a size
  16. Reserve space for dynamic content
  17. Let next/font adjust the fallback font
  18. Animate transform, not layout
  19. A short Core Web Vitals audit checklist
  20. Key takeaways

A Next.js page can score well in Lighthouse and still fail the Core Web Vitals report in Search Console, because Google judges it on real visitors, not your test run. This guide explains how Core Web Vitals in Next.js are measured, how to collect the same numbers from your own users, and the specific fixes for LCP, INP and CLS in React 19 and the Next.js 16 App Router.

The three metrics and what "good" means

Metric What it measures Good Poor
LCP (Largest Contentful Paint) When the largest image or text block in the viewport renders 2.5 s or less over 4.0 s
INP (Interaction to Next Paint) How quickly the page visually responds to clicks, taps and key presses 200 ms or less over 500 ms
CLS (Cumulative Layout Shift) The largest burst of unexpected layout movement 0.1 or less over 0.25

Each threshold is judged at the 75th percentile of page loads, segmented by mobile and desktop, and a page passes only when all three are "good". So you are optimizing for your slower visits, not the median.

INP replaced First Input Delay (FID) as a Core Web Vital on March 12, 2024. FID only measured the delay before the first interaction's handlers started. INP observes every click, tap and key press during the visit and reports roughly the worst one (ignoring one highest interaction per 50). Hovering and scrolling don't count.

Field data vs lab data: why your scores disagree

Field data comes from real users. The Chrome UX Report (CrUX) collects it from Chrome users and feeds PageSpeed Insights and the Search Console Core Web Vitals report. It reports the 75th percentile over a 28-day period, and only for pages and origins with enough traffic.

Lab data comes from Lighthouse or the DevTools Performance panel: one device, one network, usually a cold cache.

They disagree for predictable reasons (web.dev covers them in detail):

  • Lighthouse can't measure INP because nobody interacts with the page. It reports Total Blocking Time as a proxy.
  • Lab CLS only counts shifts above the fold during load. Field CLS covers the whole visit, including the ad that pushes content down mid-scroll.
  • The LCP element can differ by viewport size or personalization.
  • Real users often have resources cached.

Use lab tools to debug and field data to decide whether you have a problem. For the rest of search, see my guide to SEO in the Next.js App Router.

Measuring Core Web Vitals in Next.js from real users

CrUX spans 28 days, so fixes take weeks to show, and it skips low-traffic pages. Collect your own data with useReportWebVitals from next/web-vitals. It needs 'use client', so put it in a tiny component and render <WebVitals /> inside <body> in the root layout.

tsx
// app/_components/web-vitals.tsx
'use client'

import { useReportWebVitals } from 'next/web-vitals'

type ReportCallback = Parameters<typeof useReportWebVitals>[0]

const TRACKED = new Set(['LCP', 'INP', 'CLS'])

// Defined outside the component so the reference never changes.
const report: ReportCallback = (metric) => {
  if (!TRACKED.has(metric.name)) return

  const body = JSON.stringify({
    name: metric.name,
    value: metric.value,
    rating: metric.rating,
    id: metric.id,
    path: window.location.pathname,
  })

  navigator.sendBeacon('/api/vitals', body)
}

export function WebVitals() {
  useReportWebVitals(report)
  return null
}

Keep the callback stable: the docs warn that a new function reference receives all metrics so far, duplicating data. Then chart the 75th percentile per route and device, because a site-wide number hides the one failing template.

Fixing LCP

Find the LCP element first

Lighthouse names the element in its "Largest Contentful Paint element" diagnostic, and the DevTools LCP breakdown insight splits the time into four subparts: time to first byte, resource load delay, resource load duration and element render delay. The biggest subpart tells you where to work.

If time to first byte dominates, the problem is on the server: uncached dynamic rendering or slow queries. For the database side, see my PostgreSQL indexing guide.

Server-render it so the browser can find it

The LCP resource should be discoverable in the initial HTML. App Router pages are Server Components by default, so keep the hero there. Common mistakes: fetching hero data in useEffect, rendering the hero after a mounted flag flips, or loading it via next/dynamic with ssr: false. Each one makes the browser wait for JavaScript before requesting the image.

Load the hero image with the Next.js 16 API

next/image defaults to loading="lazy", which is wrong for the LCP image. In Next.js 16 the old priority prop is deprecated in favor of preload, and the docs say that in most cases you should use loading="eager" or fetchPriority="high" instead of preload.

tsx
// app/page.tsx (Server Component)
import Image from 'next/image'
import hero from './hero.jpg'

export default function Page() {
  return (
    <section>
      <Image
        src={hero}
        alt="Team dashboard showing this week's progress"
        sizes="(max-width: 768px) 100vw, 50vw"
        loading="eager"
        fetchPriority="high"
        style={{ width: '100%', height: 'auto' }}
      />
      <h1>Ship work your team can see</h1>
    </section>
  )
}

Why both props: next/image only drops lazy loading when you set loading, preload or the deprecated priority. fetchPriority="high" on its own still renders loading="lazy", so the request waits for layout. The fetchPriority attribute itself is Baseline since October 2024.

Always set sizes on responsive images. Without it, the browser assumes the image is 100vw wide and Next.js generates only a small 1x/2x srcset, so phones can download far more pixels than they display.

Use preload when the image would otherwise be discovered late in the document. It inserts a <link rel="preload"> into the <head>. Don't combine it with loading or fetchPriority, and skip it when different images are the LCP element at different viewport sizes. When upgrading, treat priority to preload as a rename: they behave the same in Next.js 16, and setting both throws in development.

Load fonts with next/font

On mobile, the LCP element is often a heading. next/font self-hosts Google Fonts, preloads font files by default and uses display: 'swap', so text paints immediately in a fallback font.

Don't hide the LCP element

An intro loader covers the page, or the hero starts at opacity: 0 and fades in once an animation library hydrates. Chrome ignores elements at opacity 0 for LCP and records the paint when the element actually becomes visible. Your LCP now includes JavaScript download, hydration and the animation itself.

Ship the hero visible in the server HTML. If you want motion, animate secondary elements, or animate transform from an already-visible state.

Fixing INP

Every interaction has three phases: input delay (waiting for the main thread), processing duration (your handlers and React's render) and presentation delay (layout and paint of the next frame). Any task over 50 ms is a long task, and long tasks inflate all three. It matters most on interaction-heavy screens like a real-time collaborative task board.

Reduce hydration cost with smaller client islands

During load, hydration and script evaluation compete with the user's first taps. Only Client Components hydrate, so:

  • Put 'use client' on the smallest interactive leaf, not a whole layout.
  • Pass Server Components as children into client wrappers like modals; they render on the server and stay out of the client bundle.
  • Load heavy, on-demand widgets with next/dynamic.
  • Load chat widgets and similar third-party scripts with next/script and strategy="lazyOnload".

Keep urgent updates urgent

useTransition and useDeferredValue let React render expensive updates in the background and interrupt them for new input. Use useTransition when you own the state setter, such as tab switches; it can't drive a text input. Use useDeferredValue when you receive the value:

tsx
'use client'

import { memo, useDeferredValue, useState } from 'react'

type Task = { id: string; title: string }

const Results = memo(function Results({ query, tasks }: { query: string; tasks: Task[] }) {
  const q = query.toLowerCase()
  const visible = tasks.filter((t) => t.title.toLowerCase().includes(q))
  return (
    <ul>
      {visible.map((t) => (
        <li key={t.id}>{t.title}</li>
      ))}
    </ul>
  )
})

export function TaskSearch({ tasks }: { tasks: Task[] }) {
  const [query, setQuery] = useState('')
  const deferredQuery = useDeferredValue(query)

  return (
    <>
      <input value={query} onChange={(e) => setQuery(e.target.value)} />
      <Results query={deferredQuery} tasks={tasks} />
    </>
  )
}

The memo is required: without it, Results re-renders on every keystroke anyway.

Debouncing is still the right tool for network work, like sending one search request after typing pauses. For rendering, the React docs favor useDeferredValue because it adapts to the device and can be interrupted.

Break up long tasks with scheduler.yield()

For work outside React, such as processing thousands of rows, yield to the main thread so pending input can run. This is the web.dev fallback pattern plus a 50 ms budget:

js
function yieldToMain() {
  if (globalThis.scheduler?.yield) {
    return scheduler.yield()
  }
  return new Promise((resolve) => setTimeout(resolve, 0))
}

async function processInChunks(items, work) {
  let deadline = performance.now() + 50
  for (const item of items) {
    work(item)
    if (performance.now() >= deadline) {
      await yieldToMain()
      deadline = performance.now() + 50
    }
  }
}

scheduler.yield() works in Chrome and Edge 129+ and Firefox 142+, but not Safari; MDN lists it as limited availability, so always feature-detect.

Fixing CLS

Give every image and embed a size

next/image requires width and height unless the image is statically imported or uses fill, and a fill image needs a positioned parent with real dimensions. For iframes, videos and ad slots, reserve the space in CSS with aspect-ratio (such as 16 / 9 on a video embed) or a min-height matching the most likely slot size.

Reserve space for dynamic content

Cookie banners and client-fetched recommendations should overlay the page with fixed positioning or render into reserved space. Size Suspense fallbacks, loading.tsx skeletons and next/dynamic placeholders to match the content they replace. Shifts within 500 ms of a click, tap or key press are excluded, so a "Load more" button is fine; inserting content on a timer is not.

Let next/font adjust the fallback font

When a web font swaps in, text reflows if the fallback font has different metrics. next/font counters this with adjustFontFallback, which is on by default for Google fonts and defaults to 'Arial' for local fonts (use 'Times New Roman' for serif faces). Leave it enabled.

Animate transform, not layout

Animating top, left, width or height triggers layout on every frame and can register as layout shift. Use transform: translate() for movement and transform: scale() for size changes; neither moves surrounding elements.

A short Core Web Vitals audit checklist

  1. Check CrUX field data, split by mobile and desktop.
  2. Add useReportWebVitals and track the 75th percentile per route.
  3. Identify the LCP element and its slowest subpart.
  4. Confirm the LCP element is server-rendered and visible without JavaScript.
  5. Give the LCP image loading="eager" plus fetchPriority="high" (or preload) and a correct sizes.
  6. Load fonts with next/font.
  7. Record key interactions with CPU throttling and look for long tasks.
  8. Push 'use client' down; lazy-load heavy widgets and third-party scripts.
  9. Reserve space for every image, embed and dynamic slot.
  10. Replace layout-property animations with transform.

Key takeaways

  • Google judges the 75th percentile of real visits: LCP 2.5 s or less, INP 200 ms or less, CLS 0.1 or less.
  • Lighthouse is a debugger, not the verdict. It can't measure INP.
  • In Next.js 16, priority is deprecated. For the hero, set loading="eager" and fetchPriority="high", or use preload when the image is discovered late.
  • A hero that starts at opacity: 0 is a slow LCP. Ship it visible.
  • INP improves when less JavaScript hydrates and expensive renders are deferred or split up.
  • Most CLS fixes come down to reserving space before content arrives.

If you want a second pair of eyes on a failing report, Core Web Vitals audits are part of my performance and SEO work.

Keep reading
→ All articles
02Next.js

Technical SEO in the Next.js App Router

A practical Next.js SEO guide for the App Router: server rendering, the Metadata API, canonical URLs, OG images, sitemaps, robots.txt and safe JSON-LD.

7 min read
Work togetherVishnu Vemula

Building something similar?

I help teams design, build and ship reliable web products. Tell me what you're working on.