Blog
PerformanceWeb VitalsReact

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.

CContingo· 16 May 20269 min read

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:

  1. The LCP element loads too late.
  2. The font strategy causes layout shift and delayed text paint.
  3. JavaScript hydration blocks interaction.
  4. 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 width and height — 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: optional for body fonts — accept the fallback if the web font isn't ready in 100ms.
  • size-adjust the fallback in your @font-face so 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:

TacticEffortTypical INP improvement
Defer non-critical client components ('use client' boundaries, dynamic imports below the fold)Low50–100ms
Move static content out of client components entirely (server components or static rendering)Medium100–200ms
Adopt selective / island hydration (Astro, Qwik, or partial hydration patterns)High200ms+

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 load event.
  • 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:

  1. The web-vitals library in the browser, reporting to a lightweight collector.
  2. Aggregate by template (not by URL) — you'll find the regression is usually one component.
  3. Set SLOs: 75th percentile LCP under 2.5s on every template.
  4. 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

  1. Open your highest-traffic template in Chrome DevTools, Performance tab, with CPU throttled 4x.
  2. Find the LCP element. Add fetchpriority="high", explicit dimensions, and a preload.
  3. Audit the third-party scripts. Remove anything not actively used.
  4. 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

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