Core Web Vitals for React Apps
- react
- nextjs
- javascript
- typescript
- performance
- webdev
- frontend
- softwareengineering
In Image and Asset Optimization, we got the biggest, heaviest assets under control: images sized correctly, formats chosen deliberately, fonts loaded without blocking render. This article zooms out to the metrics those decisions actually feed into. Core Web Vitals are Google's attempt to turn "does this feel fast" into something measurable, and React apps have a specific set of ways to fail each one.
This is part of the React & Next.js Complete Guide series, which started with React Fundamentals for Professionals. If you've already done the asset work from the last article, you're ahead of most teams. What's left is the part that's harder to see in a Lighthouse run alone: how your component tree renders, hydrates, and reacts to input over the lifetime of a real session.
I want to frame this correctly before going further. Core Web Vitals are a proxy for user experience, not the goal itself. Chasing a perfect Lighthouse score while ignoring what actually happens on a mid-range phone with a flaky connection gets you a green dashboard and a slow app. Treat the metrics as instrumentation, not the product.
The three metrics and why React apps trip on each one
Core Web Vitals currently track three things: Largest Contentful Paint (LCP), Interaction to Next Paint (INP), and Cumulative Layout Shift (CLS). INP replaced First Input Delay (FID) as the responsiveness metric; if you've read older performance advice referencing FID, treat it as superseded.
LCP measures how long it takes the largest visible element, usually a hero image or a headline, to render. In a React app, LCP suffers when the largest element is gated behind client-side data fetching, when it sits inside a component that only renders after hydration, or when render-blocking JavaScript delays the whole tree. Client-rendered apps without SSR are especially exposed here. The browser has to download, parse, and execute JavaScript before anything meaningful appears at all.
INP measures how responsive the page feels to real interactions across the entire session, not just the first one. It samples the worst interactions a user has, click, tap, keypress, and measures the delay before the next paint reflects that interaction. React apps trip on this when a single state update triggers an expensive re-render of a large subtree, or when a heavy synchronous computation runs on the main thread during an event handler and blocks the browser from painting.
CLS measures unexpected layout movement. This one is almost always self-inflicted in React apps: content that mounts after the initial render and pushes everything below it down, images without reserved dimensions, or a font swap that changes the width of visible text. None of these are exotic bugs. They're the direct, predictable result of not reserving space for content you know is coming.
Fixing LCP: get the biggest thing on screen fast
The fix for LCP in a React context usually comes down to reducing what has to happen before the largest element paints. Server-side rendering or static generation gets meaningful markup to the browser before any JavaScript runs, which matters far more for LCP than any client-side optimization.
// app/products/[id]/page.tsx
import { getProduct } from "@/lib/products";
export default async function ProductPage({
params,
}: {
params: { id: string };
}) {
const product = await getProduct(params.id);
return (
<article>
<img
src={product.heroImageUrl}
alt={product.name}
width={1200}
height={630}
fetchPriority="high"
/>
<h1>{product.name}</h1>
<p>{product.description}</p>
</article>
);
}
Fetching product on the server before returning markup means the hero image and headline are already in the HTML the browser receives. There's no client-side useEffect waiting to fire a request after hydration. The fetchPriority="high" attribute tells the browser to prioritize this specific request over other assets competing for bandwidth, which matters when the LCP element is an image rather than text.
The pitfall teams hit here is doing the server fetch correctly but still wrapping the LCP element in a client component that needs to hydrate before it's visually complete, say, an image gallery with client-side state for the active thumbnail. If the largest element depends on client JavaScript to render its final visual state, you've reintroduced the exact delay SSR was supposed to remove. Keep whatever renders the LCP element as a Server Component, and push the interactive parts (thumbnail switching, zoom) into a smaller client component nested underneath it.
Fixing INP: stop blocking the main thread
INP problems in React almost always trace back to one of two causes: a re-render that's larger than it needs to be, or synchronous work that runs on the main thread during an interaction instead of being deferred.
useTransition exists specifically for the case where an interaction triggers an update that doesn't need to block the next paint. Marking a state update as a transition tells React it's allowed to interrupt that work if something more urgent, like the visual feedback for the click itself, needs to happen first.
import { useState, useTransition } from "react";
function SearchResults({ query }: { query: string }) {
const [results, setResults] = useState<Product[]>([]);
const [isPending, startTransition] = useTransition();
function handleFilterChange(nextFilter: string) {
startTransition(() => {
setResults(filterProducts(query, nextFilter));
});
}
return (
<div aria-busy={isPending}>
<FilterControls onChange={handleFilterChange} />
<ProductList products={results} dimmed={isPending} />
</div>
);
}
Without startTransition, calling setResults with an expensive filter operation on every keystroke or click blocks the browser from painting the immediate visual feedback (a highlighted filter button, a pressed state) until the filtering finishes. Wrapping it in a transition lets React deprioritize that update relative to more urgent ones, which is exactly what INP is measuring.
The other common cause is a context value that changes on every render of a high-level provider, forcing every consumer deep in the tree to re-render even when the specific data they read hasn't changed. This isn't unique to INP. It worsens general responsiveness across the board, but it shows up clearly in INP measurements because the re-render happens synchronously in response to user input. Splitting large contexts into narrower ones, or memoizing the provider value, keeps re-render scope proportional to what actually changed.
Fixing CLS: reserve space before content exists
CLS is the metric most directly fixable by discipline rather than architecture. Every element that will occupy space on the page should have that space reserved before the content arrives, whether that's an image, an ad slot, a banner that loads after a feature flag check, or a font that renders differently than its fallback.
function Avatar({ src, alt }: { src: string; alt: string }) {
return (
<img
src={src}
alt={alt}
width={40}
height={40}
style={{ aspectRatio: "1 / 1" }}
/>
);
}
Explicit width and height attributes let the browser calculate the image's aspect ratio before the file has downloaded, reserving the correct box in the layout. This is a smaller fix than it sounds like, but it's also the single most common CLS regression I've seen in review: a component built without dimensions because the design wasn't finalized yet, shipped as-is, and never revisited.
Font-related shift is subtler. If a custom font loads after the page renders with a fallback, and the two fonts have different metrics, text reflows once the custom font swaps in. font-display: optional avoids the shift entirely by not swapping if the font hasn't loaded in time, at the cost of occasionally showing the fallback font permanently for that page view. font-display: swap guarantees the custom font eventually shows but accepts the shift. Which tradeoff is right depends on whether brand consistency or layout stability matters more for that specific page.
Measuring in production, not just in Lighthouse
Lab tools like Lighthouse run in a controlled environment and won't catch what real users experience on real devices and networks. The web-vitals library reports actual field data from real sessions, which is what Core Web Vitals as a ranking signal is based on.
import { onLCP, onINP, onCLS } from "web-vitals";
function sendToAnalytics(metric: { name: string; value: number; id: string }) {
const body = JSON.stringify(metric);
navigator.sendBeacon("/api/vitals", body) ||
fetch("/api/vitals", { body, method: "POST", keepalive: true });
}
onLCP(sendToAnalytics);
onINP(sendToAnalytics);
onCLS(sendToAnalytics);
navigator.sendBeacon is preferred over a regular fetch call here because it's designed to survive the page unloading, which matters since these metrics often finalize right as the user navigates away. Wiring this up to your own endpoint, backed by whatever you already use for time-series data, gives you a real distribution across devices and connection speeds instead of a single lab score from a fast machine on a fast connection.
The pitfall is collecting this data and never looking at it segmented. A median INP that looks fine can hide a genuinely bad experience for the twenty-fifth percentile of low-end devices, and that's exactly the segment most likely to bounce. Segment by device class or connection type if your analytics setup allows it, rather than trusting a single aggregate number.
Key takeaways
- LCP, INP, and CLS each fail for different, mostly self-inflicted reasons in React apps: client-gated rendering, oversized re-renders on interaction, and unreserved space for incoming content.
- SSR gets meaningful markup to the browser before JavaScript runs, which is the highest-leverage LCP fix available in a React or Next.js app.
- `useTransition` lets React deprioritize expensive state updates relative to the immediate visual feedback of an interaction, which directly improves INP.
- Explicit width and height on images, and a deliberate `font-display` choice, prevent the layout shift that CLS penalizes.
- Lab tools like Lighthouse don't reflect real user conditions. Ship the `web-vitals` library to production and look at the data segmented by device and connection, not just the aggregate.
- Treat Core Web Vitals as a proxy for user experience, not the target itself. A green score with a slow, janky app underneath it means the metric stopped measuring what it was meant to.
Frequently asked questions
What replaced First Input Delay (FID) as a Core Web Vital?
Interaction to Next Paint (INP) replaced FID. FID only measured the delay before the first interaction was processed, while INP samples responsiveness across the entire session and reports the worst interaction, which is a more representative picture of how an app actually feels to use.
Does using Server Components automatically improve Core Web Vitals?
It helps LCP significantly, since Server Components ship rendered markup instead of requiring client-side JavaScript to produce the same output. It doesn't automatically fix INP or CLS, since those depend on how the client-rendered parts of your app behave and whether you've reserved layout space correctly.
Is a good Lighthouse score enough to confirm good Core Web Vitals?
No. Lighthouse runs in a simulated, controlled environment on one device profile. Real Core Web Vitals, the ones that affect search ranking, come from field data collected across actual user sessions with the `web-vitals` library or a similar real-user-monitoring tool.
Why does my CLS score look fine locally but bad in production?
Local testing usually happens on a fast connection where fonts, images, and third-party scripts load quickly enough that any shift is barely visible. On slower production connections, the same content arrives later relative to the initial paint, making the shift far more noticeable and more likely to register against the CLS threshold.
Should I use useTransition for every state update triggered by user interaction?
No. Reserve it for updates that are expensive enough to risk blocking the next paint, like filtering a large list or recalculating derived data. Wrapping trivial updates in a transition adds indirection without a measurable benefit, since there's nothing costly to deprioritize in the first place.
How much does third-party JavaScript affect Core Web Vitals in a React app?
Often more than the app's own code. Analytics scripts, chat widgets, and ad tags run on the same main thread as your React app and can single-handedly blow past INP and LCP budgets. Load them with `next/script`'s `lazyOnload` or `worker` strategies where the vendor supports it, rather than treating them as free additions to the page.
Explore more on

About the author
I'm Aman Kumar Singh, a software engineer in Noida, India building scalable full-stack products with React, Next.js, Node.js, NestJS, PostgreSQL, Redis, and AWS. I write about backend engineering, distributed systems, and system design.