frontend / performance / 01_core_web_vitals.md

Core Web Vitals — LCP, INP, CLS

7 min read source

Core Web Vitals — LCP, INP, CLS

TL;DR

Three Google-defined metrics that gate “good” page performance: LCP (Largest Contentful Paint — when the main content visibly appears), INP (Interaction to Next Paint — how snappy interactions feel), and CLS (Cumulative Layout Shift — visual stability). They’re measured at the 75th percentile of real users, not in lab tools. Lab tools (Lighthouse) measure proxies; field tools (CrUX, web-vitals.js) measure reality. INP replaced FID in March 2024.

Interview Q&A

Q: What are the three Core Web Vitals and their thresholds?

A:

Metric What it measures Good (p75) Needs improvement Poor
LCP time to render the largest in-viewport element (image/text/video block) ≤ 2.5s ≤ 4.0s > 4.0s
INP latency of the worst user interaction (click/tap/keypress → next paint) ≤ 200ms ≤ 500ms > 500ms
CLS sum of unexpected layout shifts (visual jank) ≤ 0.1 ≤ 0.25 > 0.25

All three at “Good” = page is “Good.” Any one in “Poor” = page is “Poor.”

Q: What is LCP and what kills it?

A: The render time of the largest text block or image visible in the initial viewport. Common causes of poor LCP:

  • Slow server response (TTFB). Optimize backend, use a CDN, push-cache.
  • Render-blocking CSS/JS. Inline critical CSS; defer non-critical.
  • Large images loaded slowly. Compress, modern formats (AVIF/WebP), <picture> + srcset, fetchpriority="high" on the LCP image.
  • Client-side rendering blocking text paint. SSR or pre-render the above-the-fold content.

The fix flow:

  1. Identify the LCP element (Chrome DevTools → Performance → LCP marker).
  2. Trace why it loaded late — DNS? Connection? Server? Render-blocking resource? JS?
  3. Optimize the slowest segment.

fetchpriority="high" on the hero image is the cheapest LCP win — tells the browser to deprioritize other resources for this one.

Q: What is INP and what kills it?

A: INP measures the latency from a user input to the next paint, across the worst interaction on the page (not the average). Long INP means clicking/tapping feels laggy.

Causes:

  • Long JavaScript tasks (>50ms) blocking the main thread.
  • Heavy event handlers doing too much synchronous work.
  • Layout thrashing — reading then writing layout properties in a loop.
  • Synchronous third-party scripts (analytics, A/B testing libraries).

Fixes:

  • Break up long taskssetTimeout, scheduler.yield(), requestIdleCallback.
  • Move work off the main thread with Web Workers.
  • useTransition in React 18+ marks an update non-urgent so it doesn’t block typing.
  • Debounce expensive handlers.

Q: What is CLS and what kills it?

A: Sum of all unexpected layout shifts during the page’s lifetime (specifically: the largest shift in each session window). 0 is perfect; anything > 0.1 is noticeable.

Causes:

  • Images without dimensions — when the image loads, it pushes content down.
  • Ads / iframes without reserved space.
  • Web fonts causing FOIT/FOUT — text wraps differently when the font loads.
  • Dynamic content injected above the fold (cookie banner, sticky header appearing).

Fixes:

  • width/height/aspect-ratio on every image — browser reserves the box.
  • min-height on ad/iframe containers.
  • font-display: swap + size-adjust to match fallback font metrics.
  • Skeleton loaders during data fetch instead of “empty → full” pop-in.

Q: How do you measure Core Web Vitals in production?

A: Two complementary sources:

  • Field data (real users):

    • CrUX (Chrome User Experience Report) — aggregated real-user data from Chrome, queryable via PageSpeed Insights API or BigQuery.
    • web-vitals library — ships in your page, reports each metric to your analytics:
      import { onLCP, onINP, onCLS } from "web-vitals/attribution";
      onLCP((m) => sendToAnalytics({ name: "LCP", value: m.value, attribution: m.attribution }));
      onINP((m) => sendToAnalytics({ name: "INP", value: m.value }));
      onCLS((m) => sendToAnalytics({ name: "CLS", value: m.value }));
    • The /attribution variant includes why it was slow — element selector, load source, the slow interaction’s element.
  • Lab data (controlled environment):

    • Lighthouse / PageSpeed Insights — simulated load, useful for regression testing.
    • WebPageTest — more realistic conditions, multiple test locations.
    • Chrome DevTools → Performance / Lighthouse panels — local debugging.

Field data is what Google rewards (search ranking). Lab data is what you debug.

Q: Why did INP replace FID?

A: First Input Delay (FID) measured only the first interaction’s input delay (the time from event to handler running). Two problems:

  1. It only measured the first — late-page interactions (scroll, click) were invisible.
  2. It only measured input delay (browser dispatching the event), not the full interaction latency (event → handler → re-render → paint).

INP (Interaction to Next Paint) measures the worst interaction’s full lifecycle. It’s a fairer reflection of perceived responsiveness. Replaced FID as a Core Web Vital on March 12, 2024.

Q: What’s the relationship between TTFB, FCP, and LCP?

A: Sequential in the load timeline:

Metric Definition
TTFB (Time To First Byte) request fires → first byte of response
FCP (First Contentful Paint) first DOM text/image painted
LCP (Largest Contentful Paint) largest text/image fully painted

Improving TTFB benefits everything downstream. FCP is a useful signal but not a Core Web Vital itself.

Q: How do you debug a poor LCP?

A:

  1. Open DevTools → Performance, record a page load.
  2. Look for the LCP marker — Chrome highlights the LCP element in the screenshot.
  3. Check the network timeline: when did the LCP resource start? Why so late?
    • If it’s an image discovered late by the preload scanner: add <link rel="preload" as="image"> or fetchpriority="high".
    • If the response started late (high TTFB): backend / CDN issue.
    • If it’s a text element waiting for the web font: optimize font loading (see 07_image_and_font_optimization.md).
  4. Look for render-blocking resources — CSS/JS in <head> that block paint. Inline critical CSS, defer non-critical, async/defer scripts.
  5. Confirm with field data — local fixes don’t always reflect what real users see.

Q: A site has a great LCP but a terrible INP. Where do you look?

A: Long tasks. Open Performance → record a click → find the long task blocking the event handler or the next paint.

  • Third-party scripts (analytics, ads, chat widgets) are common culprits — they hijack the main thread.
  • Large hydration in SSR apps — the page renders but isn’t interactive because hydration is busy.
  • React’s render after setState if the work is heavy — useTransition / useDeferredValue to defer non-urgent work.

The attribution variant of web-vitals tells you which element was clicked and what handler ran — start there.

Q: What’s a realistic performance budget you’d set?

A: (See README table.) Per-route, per-metric:

  • LCP < 2.5s p75 across all routes (Good).
  • INP < 200ms p75.
  • CLS < 0.1 p75.
  • Per-route JS bundle ceiling (e.g., < 200 KB transferred).
  • CI gate: fail the build if Lighthouse CI or bundle-size check exceeds the budget.

A budget without enforcement drifts the moment people are busy. The senior answer is always: budget + measurement + CI gate.

Gotchas / edge cases

  • CLS isn’t “cumulative across the whole page lifetime” anymore — since 2021, it’s “the largest shift cluster in any 5s session window.” Long-lived SPAs aren’t penalized for occasional cumulative drift.
  • INP requires Chrome 96+ field data — the metric exists in older browsers but isn’t reported the same way.
  • Lab LCP ≠ field LCP — lab simulates one load; field reflects warmed caches, varied networks, etc.
  • <img loading="lazy"> on the LCP image hurts LCP — lazy-loading delays the very thing you’re trying to render fast. Eager + fetchpriority="high" for the hero.
  • requestAnimationFrame callbacks count toward INP if they’re after an interaction. Long rAF work after a click adds latency.
  • CLS during navigation (Next.js route change, etc.) doesn’t count — it’s only “unexpected” shifts on a stable page.

What a senior is expected to say

  • “Three vitals: LCP (visible content), INP (responsiveness), CLS (stability). Each measured at p75 of real users — field data, not lab. INP replaced FID in 2024.”
  • “Budget per route, per metric, gated in CI. A budget without enforcement is a wish.”
  • “LCP: identify the element, trace why it loaded late (TTFB, render-block, image discovery). fetchpriority='high' on the hero is the cheapest win.”
  • “INP: long tasks blocking the main thread. Break up work with scheduler.yield, move heavy work to Workers, mark non-urgent updates with useTransition.”
  • “CLS: reserve space — width/height/aspect-ratio on images, min-height on dynamic containers, font-display: swap + matching metrics.”
  • “I use web-vitals with /attribution to capture not just the metric but what caused it — element selector, slow handler, etc.”

Cross-references

Further reading