Skip to content

Performance8 min read

Core Web Vitals for React: Fix INP, LCP and CLS

Core Web Vitals for React apps: how to measure and fix INP, LCP and CLS with real code, current thresholds, and React 19 and Next.js patterns that work.

Core Web Vitals for React apps come down to three metrics: Largest Contentful Paint (LCP) should be 2.5 seconds or less, Interaction to Next Paint (INP) 200 milliseconds or less, and Cumulative Layout Shift (CLS) 0.1 or less, at the 75th percentile of real visits. In React, LCP usually suffers from client-side rendering and late-discovered hero images, INP from expensive re-renders on click or keypress, and CLS from content that loads without reserved space.

This guide goes metric by metric: what it measures, why React apps fail it, and the code that fixes it.

Key takeaways

  • The current Core Web Vitals are LCP, INP and CLS. INP replaced First Input Delay (FID) in March 2024.
  • Measure with field data (CrUX, Search Console, the web-vitals library). Lighthouse alone cannot tell you your INP.
  • For LCP: render the main content in HTML (SSR or prerendering), and make the hero image discoverable and high priority.
  • For INP: keep event handlers small, mark heavy updates as transitions, virtualize long lists, and yield to the main thread.
  • For CLS: give images, embeds, ads and skeletons fixed dimensions, and never insert content above what the user is reading.

Core Web Vitals thresholds

Metric Measures Good Needs improvement Poor
LCP Loading: when the largest content element renders ≤ 2.5 s 2.5 to 4 s > 4 s
INP Responsiveness: delay from interaction to next paint ≤ 200 ms 200 to 500 ms > 500 ms
CLS Visual stability: unexpected layout movement ≤ 0.1 0.1 to 0.25 > 0.25

A page passes only if all three are good at the 75th percentile, split by mobile and desktop. These come from web.dev's Web Vitals overview, which is the source of truth for definitions.

How to measure Core Web Vitals in a React app

Lab tools are good for debugging; field data tells you what users feel.

  • PageSpeed Insights shows both: CrUX field data at the top (if your site has enough traffic) and a Lighthouse lab run below.
  • Search Console groups URLs by status in the Core Web Vitals report.
  • Chrome DevTools Performance panel shows live LCP, INP and CLS as you interact, and points to the slow element or interaction.
  • The web-vitals library reports real values from your users to your own analytics.

Here is a small reporter using web-vitals v5:

TypeScript
// src/reportWebVitals.ts
import { onCLS, onINP, onLCP, type Metric } from 'web-vitals'

function send(metric: Metric) {
  const body = JSON.stringify({
    name: metric.name,
    value: metric.value,
    rating: metric.rating,
    id: metric.id,
    page: location.pathname,
  })
  navigator.sendBeacon?.('/api/vitals', body) ||
    fetch('/api/vitals', { body, method: 'POST', keepalive: true })
}

onCLS(send)
onINP(send)
onLCP(send)

Call it once from your entry file. When a metric is bad, switch the import to web-vitals/attribution; it tells you which element was the LCP and which interaction caused the slow INP, which saves hours of guessing.

In Next.js, the useReportWebVitals hook from next/web-vitals gives you the same data without adding the library yourself.

Fix LCP in React

LCP is the time until the biggest visible element (usually a hero image or the main heading) renders. web.dev splits it into four parts: time to first byte, resource load delay, resource load duration and element render delay. React apps tend to lose in two places.

Problem 1: client-side rendering delays everything

In a classic Vite or Create React App SPA, the server returns an empty <div id="root">. The browser has to download, parse and run your JavaScript, maybe fetch data, and only then render the heading. The LCP element does not exist until all of that is done.

The fix is to send real HTML: server rendering or static prerendering with Next.js, React Router framework mode, or a build-time prerender step. This is the single biggest LCP win for most SPAs, and it also matters for SEO, which I explain in why React SPAs don't rank.

Problem 2: the hero image is discovered late or lazy-loaded

If the hero image is set as a CSS background, injected after a data fetch, or has loading="lazy", the browser starts downloading it late. Make it a real <img> in the initial HTML with high priority:

TSX
export function Hero() {
  return (
    <img
      src="/images/hero-1200.webp"
      srcSet="/images/hero-800.webp 800w, /images/hero-1200.webp 1200w"
      sizes="100vw"
      width={1200}
      height={630}
      alt="Mountain view over Kathmandu valley"
      fetchPriority="high"
    />
  )
}

React 19 supports the fetchPriority prop directly. Never put loading="lazy" on the LCP image.

In Next.js 16, the priority prop on next/image is deprecated in favor of preload. The docs suggest loading="eager" or fetchPriority="high" in most cases, and preload when you want a <link rel="preload"> in the head:

TSX
import Image from 'next/image'
import hero from './hero.jpg'

export function Hero() {
  return <Image src={hero} alt="Product dashboard" fetchPriority="high" loading="eager" />
}

More detail on images and fonts lives in my post on Next.js image and font optimization.

Other LCP fixes

  • Fonts: self-host and preload the primary font, and use font-display: swap or next/font so text renders immediately.
  • Render-blocking CSS and JS: keep critical CSS small and defer third-party scripts.
  • Slow server: cache HTML at the CDN. A fast TTFB lifts everything after it.

Fix INP in React

INP looks at the interactions a user makes during the visit (clicks, taps, key presses) and reports one of the slowest. Each interaction has three parts: input delay (the main thread is busy when the user clicks), processing duration (your event handlers and the re-render they trigger), and presentation delay (layout and paint of the next frame).

In React, processing duration is usually the problem. A keystroke in a search box sets state at the top of a big tree, and React re-renders hundreds of components before the browser can paint the typed character.

Mark heavy updates as transitions

TSX
import { useState, useTransition } from 'react'

type Product = { id: string; name: string }

export function ProductFilter({ products }: { products: Product[] }) {
  const [query, setQuery] = useState('')
  const [filtered, setFiltered] = useState(products)
  const [isPending, startTransition] = useTransition()

  function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
    const value = e.target.value
    setQuery(value) // urgent: the input updates right away
    startTransition(() => {
      setFiltered(products.filter((p) => p.name.toLowerCase().includes(value.toLowerCase())))
    })
  }

  return (
    <>
      <input value={query} onChange={handleChange} />
      <ProductGrid items={filtered} dimmed={isPending} />
    </>
  )
}

The input updates in one fast render. The expensive grid update is interruptible, so if the user types another character, React drops the stale render and starts the new one. useDeferredValue gives you the same effect when you do not own the state setter:

TSX
const deferredQuery = useDeferredValue(query)
const results = useMemo(() => search(items, deferredQuery), [items, deferredQuery])

Render less

  • Move state down. State that lives near the root re-renders everything under it. Keep it in the component that needs it.
  • Virtualize long lists. Rendering 2,000 table rows on every filter change is slow no matter how you memoize. Libraries like TanStack Virtual render only what is on screen.
  • Use the React Compiler to get automatic memoization instead of hand-written useMemo and useCallback. I cover how it changes your code in React Compiler vs useMemo and useCallback.
  • Keep the DOM small. Large DOMs make the presentation part slower. content-visibility: auto helps for long pages.

Yield to the main thread in event handlers

When work outside React is heavy (analytics, validation, syncing to storage), split it so the browser can paint first:

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

async function handleSave() {
  setStatus('saving') // visible feedback first
  await yieldToMain()
  trackEvent('save_clicked')
  await persistDraft()
}

scheduler.yield() is available in Chromium browsers but not yet Baseline, so keep the fallback. Watch out for third-party scripts too: a chat widget or tag manager running long tasks adds input delay to every interaction on the page.

Fix CLS in React

CLS adds up unexpected layout shifts. React apps cause them in predictable ways.

Reserve space for images and media

Always set width and height (or aspect-ratio), so the browser can reserve the box before the file loads:

TSX
<img src={post.cover} width={1200} height={630} alt={post.title} className="h-auto w-full" />

For embeds and ads with unknown content, give the container a minimum height:

CSS
.ad-slot {
  min-height: 250px;
}

Make loading states the same size as loaded content

A spinner that is 40px tall, replaced by a 600px list, shifts everything below it. Skeletons should match the final layout's dimensions. With Suspense, design the fallback to occupy the same space:

TSX
<Suspense fallback={<ReviewsSkeleton rows={5} />}>
  <Reviews productId={id} />
</Suspense>

Do not inject content above the reader

Cookie banners, "new version available" bars and promo strips pushed in at the top after load are classic CLS sources. Overlay them (fixed position) or reserve their space from the first render. Shifts within 500 ms of a user's own input do not count, so expanding an accordion on click is fine.

Fonts

When the web font loads and has different metrics than the fallback, text reflows. next/font generates a fallback with adjusted metrics automatically; outside Next.js, use size-adjust and related descriptors in @font-face.

Common Core Web Vitals mistakes in React projects

  • Optimizing for the Lighthouse score only. It runs one simulated load and does not measure INP. Watch field data.
  • Lazy-loading everything, including the hero image and above-the-fold components.
  • Using useMemo everywhere and calling it done. Memoization does not help if the component tree is simply too large.
  • Fetching the main content in useEffect. It waits for JS, then render, then fetch, then render again. That is a slow LCP by design.
  • Adding third-party scripts without measuring. They often cost more than your own code.

FAQ

What are the three Core Web Vitals in 2026?

Largest Contentful Paint (LCP) for loading, Interaction to Next Paint (INP) for responsiveness, and Cumulative Layout Shift (CLS) for visual stability. INP replaced First Input Delay as a Core Web Vital in March 2024.

What are good Core Web Vitals scores?

LCP of 2.5 seconds or less, INP of 200 milliseconds or less, and CLS of 0.1 or less, measured at the 75th percentile of real page loads, separately for mobile and desktop.

Why is INP bad in my React app?

Usually because an interaction triggers a large synchronous re-render or heavy work in an event handler, which blocks the main thread before the browser can paint. Smaller re-renders, startTransition, useDeferredValue, list virtualization and yielding to the main thread are the main fixes.

Why do Lighthouse and Search Console show different results?

Lighthouse runs a single lab test on a simulated device, and a standard page-load run cannot measure INP because nobody interacts with the page. Search Console and PageSpeed Insights field data come from real Chrome users over 28 days. Google uses field data for its page experience signals.

Wrapping up

Start with field data, find the worst metric on your most important template, and fix that first. For most React apps the order is: get real HTML on the first response for LCP, shrink re-renders for INP, and reserve space for anything that loads late for CLS. For a broader list of React-specific fixes, see my React performance optimization checklist.

  • #performance
  • #core-web-vitals
  • #react
  • #nextjs
  • #seo