How do you optimize Core Web Vitals?
LCP: ship the hero image fast (CDN, format, priority). INP: keep main-thread tasks short. CLS: reserve space for everything that loads later.
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+sizesfor 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: swaporoptional; avoid late-arriving@font-facerules. - 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), orrequestIdleCallbackto insert yield points. TheisInputPendingAPI tells you to bail out early. useTransition+useDeferredValuein 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
offsetWidthafter 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 CSSaspect-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-vitalsJS 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
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.