webdev.complete
⚑ Performance & Core Web Vitals
🚦Going to Production
Lesson 106 of 117
25 min

Core Web Vitals

LCP, INP, CLS. Real-user vs lab metrics.

Performance was once a vibe: "feels fast," "feels snappy." Then Google decided that vibes weren't enough and defined three numbers, called the Core Web Vitals, that you can actually measure. If your site scores well on them, real users tend to be happy. If it doesn't, no amount of confidence will save you. Let's learn the three numbers, plus a fourth that underlies them all.

The three vitals (plus TTFB)

  • LCP - Largest Contentful Paint. How long until the biggest above-the-fold element renders. Good: < 2.5s. Poor: > 4s.
  • INP - Interaction to Next Paint. How long the worst interaction in a session takes to visually respond. Good: < 200ms.Poor: > 500ms. (Replaced FID in March 2024.)
  • CLS - Cumulative Layout Shift. How much stuff jumps around as the page loads. Good: < 0.1.Poor: > 0.25.
  • TTFB - Time to First Byte. The other vital everyone forgets. How long the browser waits before the server sends any response. Good: < 800ms.
FID is gone
First Input Delay measured just the input delay (the queue wait before the first event handler started). It missed the actual rendering. INP measures the whole round trip: input β†’ handler β†’ paint, on every interaction, taking the worst (or near-worst) one. Much more honest.

LCP: the big paint

The browser picks the "largest" element above the fold: usually a hero image, video poster, or block of text. LCP is the time from navigation start to the moment that element appears.

Typical LCP killers:

  • A hero image loaded as a normal <img> (no fetchpriority="high").
  • Slow TTFB. If the server takes 1.5s to respond, LCP can't be faster than that plus paint time.
  • Render-blocking CSS or JS in <head>.
  • Custom fonts loaded without font-display: swap.
html
<!-- Tell the browser this is the most important resource -->
<link rel="preload" as="image" href="/hero.avif" fetchpriority="high">

<!-- And the <img> itself -->
<img
  src="/hero.avif"
  alt=""
  width="1200" height="630"
  fetchpriority="high"
  decoding="async"
>

INP: are clicks responsive?

The browser's main thread is single-threaded. While your handler is running, nothing else can. If a button's onClick does 350ms of state shuffling before React commits and paints, that click contributes 350ms+ to INP.

tsx
// Bad: blocking work in the click handler
function expensiveFilter(items, q) {
  return items.filter(/* synchronous heavy work */);
}

<button onClick={() => setItems(expensiveFilter(allItems, query))}>
  Filter
</button>

// Better: defer the expensive part with startTransition
import { startTransition } from "react";

<button
  onClick={() => {
    startTransition(() => {
      setItems(expensiveFilter(allItems, query));
    });
  }}
>
  Filter
</button>

startTransition marks the update as non-urgent. React paints the click feedback immediately (cursor, focus ring, any non-transition state) and does the expensive update on the next frame.

The 200ms budget covers a lot
Most interactions on a well-built page take 30-60ms. The 200ms ceiling only gets blown when you do something slow synchronously: a giant sort, a complex layout recalc, a thousand DOM nodes mounted in one commit.

CLS: the "why did I just click that" metric

You go to tap a link. An ad loads above it. The link moves. You tap the ad. CLS measures every unexpected jump and sums them, weighted by how much screen real estate moved and how far.

Top causes:

  • Images without width/height. The browser reserves zero space until the image loads, then jumps.
  • Web fonts swapping in larger/smaller than the fallback (a.k.a. FOUT).
  • Dynamic contentinserted above existing content (banners, ads, "cookie" bars).
  • Embeds (Tweets, videos) with no reserved space.
html
<!-- Bad: layout shifts when the image loads -->
<img src="/photo.jpg" alt="">

<!-- Good: browser reserves a 1200x800 box from the start -->
<img src="/photo.jpg" alt="" width="1200" height="800">

<!-- For a hero card with dynamic content, reserve min-height -->
<section style="min-height: 480px"> ... </section>

Lab data vs field data (this is the catch)

There are two ways to measure these numbers, and they give different answers.

  • Lab data= a synthetic test from a single controlled machine. Examples: Chrome DevTools' Lighthouse panel, PageSpeed Insights' Lighthouse score, WebPageTest. Fast feedback. But it's one device, one network condition. Doesn't reflect real users.
  • Field data (RUM, real-user monitoring) = actual measurements from actual visitors. Examples: the CrUX (Chrome User Experience Report) dataset that Google publishes, your own RUM tooling (Vercel Speed Insights, SpeedCurve, New Relic).

Google's Search ranking signal uses field data, not Lighthouse. Lab is for debugging. Field is for grading.

Don't optimize for the lab score
It is entirely possible to hit a 100 Lighthouse score and have terrible real-world LCP because real users are on older phones with worse connections than the simulated test machine. Always cross-check against field data (CrUX) before declaring victory.

Where to look at each number

  • Chrome DevTools β†’ Performance panel. Record a trace, look for the LCP marker, "Long tasks" for INP investigation, the "Layout shifts" track for CLS.
  • Chrome DevTools β†’ Lighthouse panel. One-click lab run with suggestions.
  • PageSpeed Insights (pagespeed.web.dev). Lab + field side-by-side. The CrUX block at the top is the real grade.
  • Search Console β†’ Core Web Vitals report. Field data, aggregated, with URL groupings. Where Google's ranking signal comes from.
  • web-vitals JS library for collecting field data yourself.
report-vitals.ts
import { onLCP, onINP, onCLS, onTTFB } from "web-vitals";

function send(metric: { name: string; value: number; id: string }) {
  navigator.sendBeacon(
    "/api/vitals",
    JSON.stringify({ ...metric, url: location.href }),
  );
}

onLCP(send);
onINP(send);
onCLS(send);
onTTFB(send);

Drop this on every page, accept the beacons on the server, store them, plot the 75th-percentile per route. Now you have your own miniature RUM.

Quiz

Quiz1 / 4

What replaced FID as a Core Web Vital in 2024?

Recap

  • LCP < 2.5s (largest paint), INP < 200ms (interaction responsiveness), CLS < 0.1 (visual stability), TTFB < 800ms (server response).
  • INP replaced FID in March 2024 - it covers the whole input-to-paint cycle.
  • Lab data (Lighthouse, DevTools) is for debugging. Field data (CrUX, Speed Insights) is for grading and ranking.
  • Set width/height on images. Use fetchpriority="high" on the LCP image.
  • Defer non-urgent updates with startTransition to keep INP low.
  • Ship the web-vitals library to collect your own field data per route.
Built with Next.js, Tailwind & Sandpack.
Learn. Build. Ship.