Core Web Vitals for React apps: shipping LCP under 2.5s without rewriting
Most React performance problems are not React. They are images, fonts, and hydration. Fix those three and LCP follows.

The honest starting point
Most React apps we audit fail Core Web Vitals on at least 30% of URLs. The instinct is to assume the framework is the problem. It rarely is. The problem is almost always one of four things, in this order:
- The LCP element loads too late.
- The font strategy causes layout shift and delayed text paint.
- JavaScript hydration blocks interaction.
- Third-party scripts arrive on the critical path.
Fix those four, and most React apps move from "Needs improvement" to "Good" — without changing the rendering strategy at all.
LCP: get the hero painted
The LCP element is almost always the hero image or the largest heading. Treat it like a P0 asset:
``html <link rel="preload" as="image" href="/hero.avif" imagesrcset="/hero-768.avif 768w, /hero-1536.avif 1536w" imagesizes="100vw" fetchpriority="high" /> ``
And in the markup:
``jsx <img src="/hero-1536.avif" srcSet="/hero-768.avif 768w, /hero-1536.avif 1536w" sizes="100vw" width={1536} height={864} alt="..." fetchPriority="high" decoding="async" /> ``
Three things matter here:
fetchpriority="high"— without it, the browser will discover the image after parsing CSS and JS.- Explicit
widthandheight— avoids CLS and lets the browser reserve space immediately. - AVIF or WebP — JPEG is fine for a fallback, but the bytes-on-the-wire delta is huge.
If your hero is a CSS background image, stop. Move it to a real <img> so it's discoverable by the preload scanner.
Fonts: the silent CLS and LCP killer
The default React app loads Google Fonts via <link rel="stylesheet"> and uses font-display: swap. That gives you FOUT (flash of unstyled text), CLS when the swap happens, and a delayed LCP if the LCP element is text.
The fix:
- Self-host critical fonts, served from your domain or your CDN.
- Use
font-display: optionalfor body fonts — accept the fallback if the web font isn't ready in 100ms. size-adjustthe fallback in your@font-faceso the swap doesn't shift layout.
``css @font-face { font-family: "Inter"; src: url("/fonts/Inter.woff2") format("woff2"); font-display: optional; size-adjust: 107%; } ``
Hydration: scope it down
In a typical React app, the whole tree hydrates on load. The browser is busy executing your bundle while the user is trying to interact — that's where INP scores die.
Three tactics, in increasing order of effort:
| Tactic | Effort | Typical INP improvement |
|---|---|---|
Defer non-critical client components ('use client' boundaries, dynamic imports below the fold) | Low | 50–100ms |
| Move static content out of client components entirely (server components or static rendering) | Medium | 100–200ms |
| Adopt selective / island hydration (Astro, Qwik, or partial hydration patterns) | High | 200ms+ |
Most teams will get 80% of the win from the first two. Don't reach for the framework switch first.
Third-party scripts: be ruthless
Tag managers, analytics, session replay, A/B testing — each of these is a separate Russian-roulette round on your INP. We've measured single tags adding 300ms to TBT in real-user data.
Defaults we apply on every client engagement:
- Analytics — use a cookieless, server-side option (Plausible, Fathom, Vercel Analytics) where possible. If you must use GA4, load via gtag with
defer, never via GTM. - Tag manager — if you have one, audit it. Remove anything not actively used. Move container loading to
requestIdleCallback. - Session replay — sample at 5–10%, not 100%. Load after
loadevent. - Chat widgets — load on user intent (scroll past 50%, or click on a "Help" button), never on initial paint.
A measurement loop that doesn't lie
Synthetic Lighthouse scores in CI are useful guard rails. Real-user monitoring is the truth.
The setup we use:
- The
web-vitalslibrary in the browser, reporting to a lightweight collector. - Aggregate by template (not by URL) — you'll find the regression is usually one component.
- Set SLOs: 75th percentile LCP under 2.5s on every template.
- Alert when a deploy moves the p75 by more than 10% on any metric.
CrUX is fine for monthly trending, but it's too coarse to debug a regression.
What to expect
For a typical React marketing site or app shell, applying the playbook above will get you:
- LCP: from 3.5–4.5s to 1.5–2.0s.
- INP: from 250–400ms to 100–180ms.
- CLS: from 0.15–0.25 to under 0.05.
All without changing your rendering strategy. The framework is rarely the problem. The configuration almost always is.
What to do this week
- Open your highest-traffic template in Chrome DevTools, Performance tab, with CPU throttled 4x.
- Find the LCP element. Add
fetchpriority="high", explicit dimensions, and a preload. - Audit the third-party scripts. Remove anything not actively used.
- Self-host the critical font and switch to
font-display: optional.
That's typically a one-day project that moves a template from "Needs improvement" to "Good" in CrUX within 28 days.
Continue reading
GEO explained: how to rank inside ChatGPT, Perplexity and Google AI Overviews
Generative Engine Optimisation is the new layer on top of SEO. Here is what changes, what does not, and what to do this quarter.
Read
The 2026 technical SEO audit checklist (that actually moves rankings)
A no-fluff checklist covering crawl, render, index, performance and structured data — in the order we run it for clients.
Read
Want this kind of thinking on your site?
We work with a small number of teams each quarter. If SEO, GEO or shipping a faster web platform is on your roadmap, let's talk.
Start a conversation