Back to Performance
Performance
medium
very high
mid

How do you optimize Core Web Vitals on a production site?

LCP: ship the hero image fast (CDN, format, priority). INP: keep main-thread tasks short. CLS: reserve space for everything that loads later.

9 min read·~15 min to think through

Core Web Vitals are the three metrics Google uses for both UX and search ranking: LCP (perceived load speed), INP (interactivity), CLS (visual stability). Each measures a different user-felt experience and each has its own optimization playbook. Trying to optimize all three with one trick (e.g., "ship less JS") is necessary but not sufficient.

LCP — Largest Contentful Paint (target ≤ 2.5s)

The time at which the largest visible element (often the hero image or hero text block) is painted. Browsers expose it via PerformanceObserver({ type: 'largest-contentful-paint' }). Find it in Chrome DevTools → Performance → "LCP" marker, or in Lighthouse's report.

The four phases of an LCP measurement: (1) TTFB — server response time, (2) load delay — time from response to LCP resource request, (3) load time — fetching the LCP resource, (4) render delay — time to actually paint. Most LCP wins come from (1) and (2):

  • Serve from a CDN with edge caching; aim for TTFB <400ms.
  • Use modern image formats: AVIF (smallest), then WebP, with PNG/JPEG fallbacks. Use srcset + sizes for responsive serving.
  • Mark the hero with <img fetchpriority="high"> or Next.js <Image priority> so the browser preloads it.
  • Don't lazy-load above-the-fold images — that delays LCP.
  • Inline critical CSS and defer the rest. Render-blocking CSS adds directly to LCP.
  • Preconnect to your image / font CDNs; preload the LCP image (<link rel="preload" as="image">).
  • Use self-hosted fonts with font-display: swap or optional; avoid late-arriving @font-face rules.
  • Avoid huge client JS before paint — prefer SSR / streaming (Next.js App Router, Remix).
  • Use <link rel="preload" as="fetch" fetchpriority="high"> for above-the-fold JSON data so the network request starts during the HTML download.

INP — Interaction to Next Paint (target ≤ 200ms)

Replaced FID in March 2024. Measures the worst (P98) interaction latency across the session: tap, click, key press → next paint. Unlike FID, it captures interactions after first input. This is the metric most apps fail.

The contributors: input delay (main thread busy), processing time (event handler + React render), presentation delay (paint).

Optimizations:

  • Break up long tasks (>50ms blocks). Use scheduler.yield() (Chromium), setTimeout(_, 0), or requestIdleCallback to insert yield points. The isInputPending API tells you to bail out early.
  • useTransition + useDeferredValue in React: keep input handlers cheap, defer expensive derived updates.
  • Web Workers for CPU-heavy work (JSON parsing of large blobs, search indexing, image processing). The main thread should be a renderer, not a compute engine.
  • Hydration cost: defer non-critical hydration with next/dynamic({ ssr: false }) or RSC. Less hydration = less main-thread work during early interactions.
  • Event delegation instead of attaching listeners to thousands of rows.
  • Debounce input handlers that derive expensive views.
  • Avoid forcing layout in click handlers (any read of offsetWidth after a write triggers sync layout).
  • Memoize heavy components so click handlers don't trigger giant re-renders.

CLS — Cumulative Layout Shift (target ≤ 0.1)

Sum of all unexpected layout shifts across the page lifetime. Each shift's impact fraction × distance fraction contributes; the worst session 5-second window scores.

Optimizations:

  • Always set width/height (or CSS aspect-ratio) on <img> and <video> so the browser reserves space before the asset loads.
  • Reserve space for ads, embeds, banners with fixed-height containers. Late-loading content pushing the page down is the #1 cause of CLS regressions.
  • Don't insert content above the fold after load. Cookie banners, "subscribe to our newsletter" toasts, late personalization headers — all common CLS killers. Inject them at the bottom of the layout, or pre-allocate space.
  • Font swaps. Use font-display: optional (no swap if not in 100ms) or preload the font so it arrives in time. A "swap" from a system font to a custom font with different metrics causes line-height shifts.
  • CSS transforms instead of layout for animations (transform: translateY(...) doesn't shift).
  • Skeletons with the same final dimensions as the loaded content.

Measurement. Use both:

  • Lab data (Lighthouse, WebPageTest, DevTools throttled mobile) — controlled, repeatable, tells you what the metric could be.
  • Field / RUM data (Chrome User Experience Report / web-vitals JS library reporting to your analytics) — tells you what real users experience. Field is what Google uses for ranking. Always optimize toward the P75.

Workflow. Read field data → identify which metric fails → run a lab profile of a slow user's journey → fix the worst contributor → re-measure in field for 28 days. Don't chase Lighthouse scores; chase the P75 field metric for each route.

Code

tsx
import Image from "next/image";

<div className="aspect-[16/9] relative">
  <Image
    src="/hero.jpg"
    alt="..."
    fill
    priority
    sizes="(min-width: 768px) 60vw, 100vw"
  />
</div>
Hero image with priority + sized container

Follow-up questions

  • How does INP differ from FID?
  • When does inlining CSS hurt rather than help?
  • How do you debug a high CLS in production?

Common mistakes

  • Optimizing lab Lighthouse score without checking field data.
  • Lazy-loading the LCP image.
  • Late-loading consent banners pushing layout.

Performance considerations

  • INP is now the hardest of the three — react interactions touch state, render, paint.

Edge cases

  • SPA navigation doesn't reset Web Vitals; consider `web-vitals` library's `reportAllChanges` for soft navs.

Real-world examples

  • Vercel and Shopify publish CrUX dashboards per route — caught a CLS regression from font swap within hours.

Related questions