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-vitalslibrary). 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-vitalslibrary reports real values from your users to your own analytics.
Here is a small reporter using web-vitals v5:
// 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:
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:
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: swapornext/fontso 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
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:
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
useMemoanduseCallback. 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: autohelps 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:
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:
<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:
.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:
<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
useMemoeverywhere 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
Keep reading
Next.js8 min read
Next.js Image and Font Optimization for Core Web Vitals
Next.js image and font optimization for Core Web Vitals: next/image sizes and preload, AVIF, next/font fallbacks, and fixes for slow LCP and layout shift.
Read articleSEO8 min read
React SEO: Why Your React SPA Isn't Ranking (and Fixes)
React SEO explained: why client-rendered React SPAs struggle in Google and how to fix it with SSR, SSG or prerendering, meta tags, sitemaps and JSON-LD.
Read articleReact8 min read
React Compiler: Do You Still Need useMemo and useCallback?
React Compiler 1.0 memoizes components for you. Learn when useMemo and useCallback are still needed, how to enable it in Vite or Next.js 16, and what breaks.
Read article