Passing Core Web Vitals in a React app: what moves LCP
How to find the element that sets your LCP, ship less before the first paint with code splitting, preload only what matters, and measure the change properly.
By M. Adnan Saleem · · 3 min read
On Qatar's national event platforms I brought core pages to Core Web Vitals compliance through memoization, virtualization, lazy loading and code splitting, which improved LCP by about 35% and TTI by about 30%. On a payments platform, dynamic code splitting cut the initial bundle by about 35%. The method is the same every time, and it starts with measuring, not with optimizing.
The three numbers
- LCP (Largest Contentful Paint): when the biggest visible element appears. Good is 2.5 seconds or less.
- INP (Interaction to Next Paint): how long the page takes to respond to a tap or key press. Good is 200 milliseconds or less.
- CLS (Cumulative Layout Shift): how much the page jumps while loading. Good is 0.1 or less.
Google judges these on real visits at the 75th percentile, so a fast laptop on office wifi tells you very little. Test with mobile emulation and a throttled connection.
Find the LCP element first
Lighthouse and the Performance panel in Chrome both name the element that counts as the largest paint. Until you know which one it is, every optimization is a guess. It is usually a hero image or a headline, and the question is always the same: what has to download and run before that one element can appear?
Ship less before the first paint
Most React apps send every screen to every visitor. Code splitting sends a screen only when it is opened:
// Before: the reports screen ships in the first bundle, to every visitor
import Reports from './Reports';
// After: it is downloaded only when someone opens it
const Reports = React.lazy(() => import('./Reports'));
<Suspense fallback={<ScreenSkeleton />}>
<Reports />
</Suspense>Split by route first, then split heavy pieces inside a route: charts, editors, maps, export dialogs. Anything below the fold or behind a click is a candidate.
Preload only what the first screen needs
Preloading is a promise that a file is urgent. Break that promise and the urgent files wait behind the others. This site is a small example: every page preloaded seven font files, four of which were used on one page each. Removing those four preloads took the Lighthouse mobile score from between 85 and 88 to 92 and cut the simulated LCP from about 4.1 seconds to 3.4, with no visible change.
Keep interactions cheap
INP suffers when one interaction re-renders too much. Two tools cover most cases. Memoization (React.memo, useMemo) stops components and calculations from repeating when their inputs did not change. Virtualization keeps long lists and tables down to the rows that are on screen.
Stop the page from jumping
Layout shift comes from content that arrives without reserved space: images, embeds, late banners, fonts that swap to a different width. Reserve the space up front:
/* The image box has its final size before the image arrives, so nothing jumps */
.hero-image { aspect-ratio: 16 / 9; width: 100%; }Measure before and after, the same way
- Run the same test three times before a change and three times after. Single runs vary too much to trust.
- Change one thing at a time, so you know what worked. I tried deferring hidden images on this site and it measured no gain, so it was dropped.
- Lab tools estimate. Real-user data, from the Chrome UX Report or your own analytics, is what Google uses, so confirm there once traffic allows.
The project behind this
Read the case study
What I built, where, and the numbers that came out of it.