cf.completefrontend
My progress
FUNDAMENTALS · MODULE 8

Core Web Vitals

Three numbers, measured on real users, that say whether a page loads fast (LCP), reacts fast (INP), and stays still (CLS): what each one measures, what makes it bad, and how to fix it.

Why Core Web Vitals exist

Picture a news site. The team runs a speed test on a fast office laptop and gets a great score. The same week, support tickets say the site "feels broken" on phones.

Readers tap the menu and nothing happens for half a second. The article text jumps down just as they start to read, because an ad loaded above it. The hero photo shows up three seconds after everything else.

Nobody on the team was lying. They were measuring the wrong thing, in the wrong place. "Page load time", the old favourite, is the moment the load event fires: when the document and all its images, scripts, and stylesheets have finished loading. It says nothing about when the user saw the main content, whether the page answered their taps, or whether things moved under their finger.

Core Web Vitals are Google's answer to that gap: three metrics, each tied to one thing a user feels, each with a clear "good" line, and each measured on real visits rather than on a developer's laptop.

Three questions a user asks

Every visit, a user silently asks three questions. Each Core Web Vital turns one question into a number you can track.

The three Core Web Vitals as of 2026. INP replaced FID (First Input Delay) as the responsiveness metric on 12 March 2024.
The user asksMetricWhat it measuresGood at the 75th percentile
Is the page here yet?LCP, Largest Contentful PaintWhen the biggest image or text block in the viewport was painted2.5 s or less
Does it react when I tap?INP, Interaction to Next PaintHow long the page takes to show a response after a click, tap, or key press200 ms or less
Does it stay still?CLS, Cumulative Layout ShiftHow much visible content moves without the user asking it to0.1 or less

The right column says "at the 75th percentile". A page does not pass because it is fast for you: it passes when at least three out of four real visits are under the line. A later chapter explains why that is the right rule.

A timeline of one 8-second visit. Loading row: the first byte arrives at 0.6 seconds, the first paint at 1.2 seconds, and the largest paint, LCP, at 2.1 seconds. Interactions row: three taps at 3.0, 4.8, and 6.6 seconds take 120, 310, and 90 milliseconds, so INP is 310 milliseconds. Layout shifts row: a shift of 0.06 at 1.6 seconds and one of 0.03 at 6.0 seconds, in separate windows, so CLS is 0.06.TTFB 0.6 sFCP 1.2 sLCP 2.1 sloadingLCP 2.1 s120 ms310 ms90 msinteractionsINP 310 ms0.060.03layout shiftsCLS 0.060 s1 s2 s3 s4 s5 s6 s7 s8 s
LCP is decided once, early. INP and CLS keep watching for the whole visit and report the worst they saw: here the second tap and the first shift.

Why the whole industry tracks them

Google uses Core Web Vitals as one of the signals in search ranking (the "page experience" signals). It is a small signal next to relevance, but it made every large site measure the same three things the same way. That shared vocabulary is the real win: a product manager, a backend engineer, and an SEO team can read the same dashboard.

Later lessons in this module cover the fixes in depth: loading strategies, images, fonts, runtime performance, and budgets. This lesson is the map that tells you which fix a given number is asking for.

The mental model: one visit, three moments

Keep one picture in your head: a single visit drawn as a line from the moment the user taps a link to the moment they leave. Each vital looks at a different stretch of that line and keeps its own kind of number.

  • LCP is a moment early in the visit. It is a time in seconds, measured from the start of the navigation. It is decided once, during loading, and stops updating as soon as the user taps, clicks, scrolls, or presses a key.
  • INP is about the whole visit. It is a duration in milliseconds: roughly the slowest response to any tap, click, or key press, from the first to the last. A page that loads fast but freezes on the third click still fails.
  • CLS is about the whole visit too. It is a score with no unit, built from every unexpected movement of visible content. A shift ten minutes in, when an infinite list loads a banner, counts just like a shift during loading.

This split tells you where to look when a number is bad. A bad LCP is usually a loading problem: the network, the server, or the order in which the browser fetches things. A bad INP is usually a main thread problem: too much JavaScript at the wrong time. A bad CLS is usually a layout problem: no space reserved for something that arrived late.

An everyday comparison

Think of a restaurant. LCP is how long until your main dish arrives, INP is how quickly a waiter responds when you raise your hand (judged on your slowest wait, not the first), and CLS is whether someone keeps moving your plate while you eat. A restaurant can be great at one and awful at another, and guests remember the worst, so all three have to be good.

Three bands for each metric

Each metric has two lines that split visits into three bands: good, needs improvement, and poor. A score is "good" at or below the first line, and "poor" above the second.

Thresholds as published on web.dev. TTFB and FCP are not Core Web Vitals, but they share the same three-band scheme.
MetricGoodNeeds improvementPoor
LCP0 to 2.5 sover 2.5 s up to 4 sover 4 s
INP0 to 200 msover 200 ms up to 500 msover 500 ms
CLS0 to 0.1over 0.1 up to 0.25over 0.25
TTFB (helper)0 to 0.8 sover 0.8 s up to 1.8 sover 1.8 s
FCP (helper)0 to 1.8 sover 1.8 s up to 3 sover 3 s
Three scales drawn to size. LCP is good up to 2.5 seconds, needs improvement up to 4 seconds, and is poor beyond. INP is good up to 200 milliseconds, needs improvement up to 500, and is poor beyond. CLS is good up to 0.1, needs improvement up to 0.25, and is poor beyond.LCPgoodneeds workpoor2.5 s4 sINPgoodneeds workpoor200 ms500 msCLSgoodneeds workpoor0.10.25
The middle band is officially called needs improvement. A page passes only when the 75th percentile of all three lands in the good band, for phones and desktops separately.

When does a page pass?

A page passes the Core Web Vitals assessment when all three metrics are good at the 75th percentile of real visits, measured separately for phones and for desktops. Two out of three is a fail. A great desktop score does not cover a poor mobile score: tools like PageSpeed Insights and Search Console show the two separately.

Check your understanding · Exercise 1

Which of these problems would show up mainly in INP? Select all that apply.

LCP: when the main content shows

LCP, Largest Contentful Paint, is the time from the start of the navigation until the largest image or text block inside the viewport is painted. It is the browser's best guess at "the main content is here": usually the product photo on a product page, the headline or first paragraph on an article, and the hero image on a landing page.

What can be the LCP element

  • An img element, including an image inside an svg. For an animated image, the first frame counts.
  • A video element: its poster image, or its first frame when there is no poster.
  • An element with a background image loaded through CSS url().
  • A block-level element that contains text, such as a p, an h1, or a div with text in it.

The size used is only the part that is visible inside the viewport. Anything below the fold, clipped by overflow, or hidden with opacity: 0 does not count. For images, the browser also takes the smaller of the displayed size and the file's real size, so stretching a tiny image to fill the screen does not make it the LCP element. Chrome also ignores images with very little detail per pixel, so a blurry placeholder does not end LCP early.

LCP is a running guess

The browser does not know in advance which element will be largest. As the page paints, it reports a new candidate each time something bigger appears: first the logo, then the headline, then the hero image. The last candidate before the user interacts is the final LCP. Once the user taps, scrolls, or presses a key, the browser stops looking, because from then on the page changes for the user's reasons.

ts
new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    // Each entry is a new, larger candidate. The last one wins.
    const lcp = entry as LargestContentfulPaint;
    console.log("LCP candidate", Math.round(lcp.startTime), lcp.element);
  }
}).observe({ type: "largest-contentful-paint", buffered: true });
Paste this into the console of any page to watch the candidates arrive. buffered: true replays entries that happened before the observer started.

Split LCP into four parts

A single LCP number tells you that something is slow, not what. The most useful habit is to split the time into four parts that follow each other with no gaps. Each part has different causes and different fixes.

  1. TTFB, time to first byte. From the start of the navigation until the first byte of the HTML arrives. Redirects, DNS, the connection, and the server's work all land here.
  2. Resource load delay. From TTFB until the browser starts downloading the LCP image. This is the time the browser did not yet know the image existed, or knew but chose to fetch other things first.
  3. Resource load duration. How long the image itself takes to download. Bytes and distance land here.
  4. Element render delay. From the end of the download until the image or text is actually painted. Render-blocking CSS, scripts, web fonts, and client-side rendering land here.

When the LCP element is text, parts 2 and 3 are zero: there is no resource to fetch, so everything after TTFB is render delay, including any wait for a web font. web.dev suggests a healthy page spends roughly 40% of its LCP on TTFB, 40% on the image download, and under 10% each on the two delays. The delays are pure waste: the browser is waiting for no good reason.

An LCP of 3.8 seconds on a product page, drawn as one bar split into four parts: time to first byte 0.9 seconds, resource load delay 1.3 seconds, resource load duration 1.0 second, and element render delay 0.6 seconds. A dashed line at 2.5 seconds marks the good limit, which falls inside the load duration. Under each part is its usual fix: cache the HTML at the CDN, put the img in the server HTML, use a right-sized AVIF, and defer scripts.2.5 s goalLCP 3.8 sTTFB0.9 scache HTMLat the CDNload delay1.3 simg in theserver HTMLload duration1.0 sAVIF, rightpixel sizerender0.6 sdeferscripts0 s1 s2 s3 s4 stime since the navigation started
The biggest part is the load delay: 1.3 s in which the browser did not yet know about the photo. Fix the biggest part first; the two delays should each be under a tenth of the total.

What makes LCP bad, part by part

PartTypical causesFixes
TTFBSlow server rendering, uncached HTML, redirects, a far-away originCache HTML at a CDN, stream the response, remove redirect hops
Load delayImage added by JavaScript, set as a CSS background, lazy-loaded, or queued behind other requestsPut a real img in the server HTML, add fetchpriority="high", preload it if it must come from CSS, never lazy-load it
Load durationLarge file, old format, wrong pixel size for the screen, no CDNRight-size with srcset, use AVIF or WebP, serve from an image CDN near the user
Render delayRender-blocking CSS and scripts, a web font hiding text, client-side rendering, an A/B test snippet that hides the pageInline critical CSS, defer scripts, use font-display, render on the server, drop anti-flicker snippets
html
<!-- In the server HTML, so the preload scanner finds it at once -->
<img
  src="/p/shoe-800.avif"
  srcset="/p/shoe-400.avif 400w, /p/shoe-800.avif 800w, /p/shoe-1200.avif 1200w"
  sizes="(max-width: 600px) 100vw, 50vw"
  width="800" height="800"
  fetchpriority="high"
  alt="Blue running shoe, side view"
/>
An LCP image done right: discoverable in the HTML, high priority, right-sized, a modern format, and with dimensions so it does not shift the layout.

The browser's preload scanner is the reason the first fix works so well. While the main parser is blocked on a script, a second, lighter parser races ahead through the raw HTML and starts downloads for every img, link, and script it sees. An image that only exists after your JavaScript runs is invisible to it. The critical-rendering-path lesson walks through this in detail.

A worked example

A product page on a mid-range phone has a p75 LCP of 3.8 s. Splitting it gives: TTFB 900 ms, load delay 1,300 ms, load duration 1,000 ms, render delay 600 ms. The load delay is the biggest part, and it is pure waste. The cause: the product photo is rendered by React after a 300 KB bundle downloads and runs.

  1. Render the img in the server HTML with fetchpriority="high". Load delay drops from 1,300 ms to about 150 ms.
  2. Serve an 800-pixel AVIF of about 90 KB instead of a 480 KB JPEG. Load duration drops from 1,000 ms to about 300 ms.
  3. Cache the HTML at the CDN for anonymous visitors. TTFB drops from 900 ms to about 350 ms.
  4. Inline the small amount of CSS above the fold and defer the bundle. Render delay drops from 600 ms to about 100 ms.

The new total is about 0.9 s, well under 2.5 s. Try the combinations in the lab below and notice which single fix moves the number most. The answer depends on which part is biggest, which is the point of splitting it.

An LCP bar split into four parts: TTFB 900 ms, load delay 1300 ms, load duration 1000 ms, render delay 600 ms. Total LCP 3.8 seconds, rated needs improvement. The biggest part is load delay.2.5 sTTFBload delayload durationrenderLCP 3.8 s: needs improvement0 s1 s2 s3 s4 s
LCP is 3.8 s: needs improvement. The biggest part is load delay; that fix moves the number most.
PartTimeShare of LCP
TTFB900 ms24%
load delay1300 ms34%
load duration1000 ms26%
render delay600 ms16%

Check your understanding · Exercise 2

A page's LCP element is the h1 headline. TTFB is 400 ms and LCP is 2.9 s. Which part is most likely to blame? Pick one.

TTFB and FCP: the metrics that explain LCP

Two more metrics are not Core Web Vitals, but you will read them every time LCP is bad. They are the first two marks on a page load's timeline, and the gaps between them and LCP tell you where to look.

TTFB: time to first byte

TTFB is the time from the start of the navigation until the first byte of the HTML response arrives. Good is 0.8 s or less. It is not just "server time". It is everything that happens before the browser can start parsing:

  • Redirects. Each hop, such as http to https or example.com to www.example.com, costs a full round trip before the real request starts.
  • Service worker startup. If a service worker controls the page and is not running, the browser has to start it first, which can take tens of milliseconds on a phone.
  • DNS lookup, TCP connection, and TLS handshake. About three round trips on a cold connection with TLS 1.3: one each for DNS, TCP, and TLS. On a 100 ms mobile round trip that alone is about 300 ms.
  • The request and the server's work. Routing, database queries, rendering HTML, and any calls to other services.
  • The first byte travelling back. Another half round trip.
A sequence diagram between the browser, a CDN edge, and the origin server. The browser requests http://shop.com and gets a 301 redirect to https://www.shop.com. It then does a DNS lookup and opens a TCP and TLS connection to the edge. It sends GET / to the edge. The edge has no cached copy, so it asks the origin, which does its server work and returns the HTML. The edge sends the first byte back to the browser. All of this is TTFB.BrowserCDN edgeOriginGET http://shop.com301 to https://wwwDNS, TCP, TLSGET https://www.shop.com/miss: GET /200 HTML after workfirst byteTTFB = every step above
TTFB is every step here, not just the server's work. The redirect costs a full extra round trip, and a cache miss at the edge adds the trip to the origin. Cached HTML at the edge would cut the last three steps to one.

This is why a CDN helps TTFB twice. The connection is made to an edge server a few milliseconds away instead of an origin across an ocean, and a cached HTML response skips the server's work entirely. Streaming server rendering helps too: the server sends the head and the page shell right away, so the first byte leaves before slow data is ready.

FCP: first contentful paint

FCP is the time until the browser paints anything from the DOM: any text, any image (including a CSS background image), an svg, or a non-blank canvas. Good is 1.8 s or less. FCP is the moment the user stops staring at a blank screen and knows the page is coming.

FCP waits on the critical rendering path: the HTML has to arrive, every render-blocking stylesheet in the head has to download and parse, and every classic script without async or defer before the content has to run. The critical-rendering-path lesson covers each step; here you only need the effect. A page with 300 KB of CSS in the head cannot paint anything until all of it is in.

Read the gaps, not the numbers

Put TTFB, FCP, and LCP on one line and look at the distances between them. Each gap points at a different kind of problem.

What you seeWhat it usually meansWhere to look
TTFB is already over 1 sThe server, the network, or redirectsCDN caching, server timing, redirect chains
Big gap from TTFB to FCPRender-blocking CSS or scripts in the headInline critical CSS, defer scripts, fewer blocking requests
Big gap from FCP to LCPThe main content is late: an image found late, or content rendered by client JavaScript after an API callServer-render the main content, preload or prioritize the LCP image
FCP and LCP almost equalThe first thing painted is the main contentUsually healthy; check TTFB and the CSS

A client-side rendered app shows a typical pattern: an early FCP (a spinner or an empty shell painted from the HTML) and a late LCP, after the bundle downloads, runs, calls an API, and renders. The early FCP looks good on a dashboard and hides the real problem. This is why the vitals track LCP, not FCP.

Check your understanding · Exercise 3

A dashboard shows TTFB 300 ms, FCP 600 ms, LCP 3.4 s. Which changes are likely to help LCP most? Select all that apply.

INP: how fast the page responds

INP, Interaction to Next Paint, measures how long the page takes to show something after the user clicks, taps, or presses a key. It watches every such interaction during the whole visit and reports roughly the slowest one. Good is 200 ms or less; poor is over 500 ms.

"Next paint" is the key idea. The user does not care when your handler started or finished. They care when the screen changed: the button pressed in, the menu opened, the letter appeared in the box. INP ends at the first frame painted after your handlers ran.

Three phases of every interaction

  1. Input delay. The time from the tap until the event handlers start. The browser runs JavaScript on one main thread; if it is busy with another task, such as hydration or a third-party script, the event waits in a queue.
  2. Processing time. Your event handlers running, for all events in the interaction (for a tap: pointerdown, pointerup, click). Includes any framework work they trigger, such as a re-render.
  3. Presentation delay. The browser computing styles and layout for what changed, painting, and getting the frame to the screen. A huge DOM or a style change that touches thousands of elements makes this long.
A timeline of the main thread for one tap. A 300 millisecond hydration task started 100 milliseconds before the tap, so the tap waits 200 milliseconds: the input delay. Then the click handlers run for 80 milliseconds: processing. Then the browser takes 40 milliseconds to lay out and paint: presentation delay. The interaction's latency is 320 milliseconds.tapmainhydration task, 300 mshandlerpaintinput delay200 msprocessing80 mspresentation40 mslatency 200 + 80 + 40 = 320 ms
The handler took only 80 ms, but the user waited 320 ms. Most of it was input delay: the main thread was busy hydrating when the tap arrived.

A worked example: the user taps "Filters" 100 ms into a 300 ms hydration task. The tap waits 200 ms for that task to finish (input delay), the handler re-renders the filter panel in 80 ms (processing), and the browser takes 40 ms to lay out and paint it (presentation). The interaction's latency is 320 ms: "needs improvement", even though the handler itself was only 80 ms.

Which interaction becomes INP

Clicks, taps, and key presses count. Hovering and scrolling do not. A single tap fires several events; the interaction's latency is the longest of them. The browser times each one through the Event Timing API, a performance API that reports event durations rounded to 8 ms.

At the end of the visit, INP is the slowest interaction, with one twist: for every 50 interactions, the single slowest is ignored. A visit with 10 clicks reports the worst click; a visit with 200 key presses in an editor ignores the 4 worst and reports the fifth. That way one random hiccup on a busy page does not decide the score. A visit with no interactions at all has no INP.

Why INP replaced FID

Until 12 March 2024 the responsiveness vital was FID, First Input Delay. FID measured only the first interaction, and only its input delay. A page could pass FID with a 10 ms wait on the first click and then freeze for a second on every search keystroke. Most sites passed FID easily; many of the same sites failed INP when it arrived, because INP measures every interaction and all three phases.

What makes INP bad

  • Long tasks during loading. Hydration of a big server-rendered page, a large bundle being evaluated, or tag-manager scripts all block the main thread while the user is already tapping. These show up as input delay.
  • Heavy handlers. A handler that re-renders a whole page, sorts 10,000 items, or writes to localStorage synchronously before updating the screen. These show up as processing time.
  • Forced layout. Reading offsetHeight right after changing styles, in a loop, forces the browser to lay out again and again inside the handler.
  • Big rendering work. A DOM of many thousands of nodes, or a style change on the body that restyles everything. These show up as presentation delay.

How to fix it

Almost every INP fix is one idea: let the browser paint sooner. Do the visible part of the work first, then give the main thread back, then do the rest.

ts
function yieldToMain(): Promise<void> {
  // scheduler.yield() resumes ahead of other queued tasks where supported.
  if ("scheduler" in globalThis && "yield" in scheduler) return scheduler.yield();
  return new Promise((resolve) => setTimeout(resolve, 0));
}

button.addEventListener("click", async () => {
  showSpinnerOnButton();   // the visible change, before the next frame
  await yieldToMain();     // let the browser paint it
  applyFilters();          // the heavy part, in a later task
  await yieldToMain();
  sendAnalytics();         // nobody is waiting for this
});
Update the screen first, then yield. The interaction's latency now ends at the first paint, after showSpinnerOnButton, not after applyFilters.
  • Break up long tasks. Process a big list in chunks and yield between them, so a tap can run in the gaps. Aim for tasks under 50 ms.
  • Defer non-urgent work. Analytics, logging, and saving drafts can run after the paint, or in requestIdleCallback where the browser supports it.
  • Use the framework's tools. React's useTransition marks a re-render as interruptible, so typing stays responsive while results update.
  • Keep the DOM small. Virtualize long lists and use content-visibility: auto for long off-screen sections.

Try it in the simulator. The tap always lands 120 ms into a background task: make the task long, then split it into 50 ms chunks and watch the input delay collapse. The runtime-performance lesson later in this module goes much deeper on long tasks, scheduling, and profiling INP.

Background task:
Handler work:
Main thread timeline. A tap at 120 milliseconds waits 180 milliseconds, the handler runs 120 milliseconds, and the paint takes 40 milliseconds, for an interaction latency of 340 milliseconds, rated needs improvement.taptask340 ms02004006008001000
340 ms: needs improvement. Most of it is input delay; the tap is stuck behind the background task.
PhaseTime
Input delay180 ms
Processing120 ms
Presentation40 ms
Interaction latency340 ms, needs improvement

Check your understanding · Exercise 4

Typing in a search box takes 280 ms per key press to show the letter. The profile shows each input handler filtering 20,000 items and re-rendering the result list before returning. What helps? Select all that apply.

CLS: how much the page jumps

CLS, Cumulative Layout Shift, measures how much visible content moves when the user did not ask it to. You reach for "Cancel", an ad loads above it, and your finger lands on "Buy now"; or the paragraph you are reading slides down 150 pixels. Good is 0.1 or less; poor is over 0.25.

What counts as a layout shift

A layout shift happens when an element that was already visible changes its start position from one frame to the next. The browser calls these elements unstable. Some details matter:

  • Adding a new element is not a shift by itself. It is the existing elements it pushes that shift.
  • An element that only grows or shrinks in place, keeping its top-left corner, does not shift. The content below it does.
  • Moving something with transform does not count, because transform does not change layout. Animating top, margin, or height does.
  • Shifts within 500 ms after a click, tap, or key press are excluded: the user expects the page to change then. Scrolling is not an input for this rule, and neither is hovering.

How one shift is scored

Each shift gets a score: impact fraction times distance fraction. The impact fraction is how much of the viewport the unstable elements touch, counting both where they were and where they ended up. The distance fraction is how far they moved, as a share of the viewport's larger side.

A worked example on a phone with a 400 by 800 viewport. An article block 400 wide and 400 tall starts 200 pixels from the top, and nothing else on screen sits below it. A 160-pixel promo banner is inserted above it and pushes it down by 160 pixels.

  • Before, the block covers 200 to 600 down the screen. After, it covers 360 to 760. Together that is 200 to 760: a region 400 by 560.
  • Impact fraction: 400 times 560 out of 400 times 800, which is 0.7.
  • Distance fraction: 160 out of 800 (the larger side), which is 0.2.
  • Shift score: 0.7 times 0.2 = 0.14. One banner, and the page is already over the 0.1 line.
Two phone screens of 400 by 800 pixels. Before: a heading at the top and an article block from 200 to 600 pixels down. After: a 160 pixel promo banner has been inserted under the heading, pushing the article down to 360 to 760 pixels. The union of the article's old and new areas is 400 by 560, an impact fraction of 0.70. It moved 160 of 800 pixels, a distance fraction of 0.20. The shift scores 0.70 times 0.20, which is 0.14.beforeheadingarticleafterheadingpromoarticleold + new area:400 x 560 pximpact = 0.70moved 160 of 800 pxdistance = 0.200.70 x 0.20 = 0.14drawn at about a third of real size
One late banner is enough to cross the 0.1 line. Reserving 160 px for the banner before it loads would make both screens identical: no shift at all.

Session windows: how shifts add up

The "cumulative" in CLS used to mean "add up every shift in the visit". That punished long-lived pages unfairly: a single-page app open for an hour piled up score forever. Since 2021 CLS groups shifts into session windows and reports only the worst window.

  • A window starts with a shift and keeps growing while each next shift comes less than 1 second after the previous one.
  • A window can last at most 5 seconds. After that, the next shift starts a new window.
  • Each window's score is the sum of its shifts. CLS is the largest window score of the visit.
Six layout shifts grouped into three session windows. Window 1 has shifts of 0.03 at 0.4 seconds, 0.05 at 1.3 seconds, and 0.02 at 2.2 seconds, summing to 0.10. Window 2 has 0.08 at 4.0 seconds and 0.04 at 4.6 seconds, summing to 0.12. Window 3 has 0.01 at 8.0 seconds. CLS is the largest window sum: 0.12.shifts under 1 s apart share a window; a window lasts 5 s at mostwindow 10.030.4 s0.051.3 s0.022.2 ssum 0.10window 20.084.0 s0.044.6 ssum 0.12window 30.018.0 ssum 0.01CLS = the largest window = 0.12
A gap of 1 second or more, or a window reaching 5 seconds, starts a new window. CLS is not the total of all six shifts (0.23); it is the worst burst.

What makes CLS bad, and the fixes

CauseWhy it shiftsFix
Images and videos without dimensionsThe browser does not know the height until the file arrivesSet width and height attributes, or CSS aspect-ratio
Ads, embeds, and iframesTheir size is known only after a third party respondsReserve the slot with min-height at the most common size
Content injected above what is on screenCookie banners, "new posts" bars, promos pushed in at the topOverlay with position: fixed, or insert below the viewport, or wait for a user action
Web fonts swapping inThe fallback font has different widths, so lines rewrapMatch the fallback with size-adjust and metric overrides; preload the font
Animating layout propertiesChanging top or height each frame moves everything after itAnimate transform and opacity instead
Skeletons of the wrong sizeThe real content is taller than the placeholderSize skeletons from real data, such as a known row height
css
.ad-slot {
  min-height: 250px; /* the most common ad height here */
}
.hero img {
  width: 100%;
  height: auto;
  aspect-ratio: 16 / 9; /* space is reserved before the bytes arrive */
}
@font-face {
  font-family: "Brand Fallback";
  src: local("Arial");
  size-adjust: 105%; /* make Arial take the same space as the web font */
}
Three common CLS fixes: reserve ad space, give media a shape, and make the fallback font match the web font's size.

Check your understanding · Exercise 5

Which of these add to CLS? Select all that apply.

Field data and lab data

There are two ways to get these numbers, and they answer different questions. Field data is collected from real visits on users' own devices and networks; lab data comes from a test run on a machine you control, with fixed settings. Field data tells you what is happening. Lab data helps you find out why, and check a fix before you ship it.

Where field data comes from

  • CrUX, the Chrome User Experience Report. Google collects vitals from opted-in Chrome users on desktop and Android. It reports the 75th percentile over a rolling 28-day window, for whole origins and for URLs with enough traffic. PageSpeed Insights and Search Console show CrUX data, and it is the data Google Search uses.
  • Your own RUM, real user monitoring. A small script on your pages measures each visit and sends the values to your backend, usually with Google's web-vitals library. You see every page and every browser that supports the APIs, and you can slice by route, release, country, or device.
ts
import { onCLS, onINP, onLCP, onTTFB, type MetricWithAttribution } from "web-vitals/attribution";

function send(metric: MetricWithAttribution) {
  const body = JSON.stringify({
    name: metric.name,          // "LCP", "INP", "CLS", "TTFB"
    value: metric.value,
    rating: metric.rating,      // "good" | "needs-improvement" | "poor"
    id: metric.id,              // unique per page view
    route: location.pathname,
    release: window.__RELEASE__,
    attribution: metric.attribution, // which element, which script, which phase
  });
  navigator.sendBeacon("/rum/vitals", body);
}

onLCP(send);
onINP(send);
onCLS(send);
onTTFB(send);
The attribution build tells you why: the LCP element and its four parts, the INP target and its three phases, the element that shifted most. sendBeacon still delivers while the page is being hidden or closed, which is when INP and CLS report their final values.

Where lab data comes from

  • Lighthouse. Loads the page once with an emulated mid-range phone and a throttled network, then reports LCP, CLS during load, FCP, and TBT, Total Blocking Time: the sum of the parts of each long task over 50 ms, between FCP and the page becoming quiet. It runs in Chrome DevTools, in PageSpeed Insights, and in CI.
  • The Chrome DevTools Performance panel. Shows live LCP, INP, and CLS as you use the page, and can compare them with the CrUX numbers for the same URL. Record a trace to see every task on the main thread.

Why the two disagree

A page can score 95 in Lighthouse and still fail Core Web Vitals in Search Console. The lab run is one visit under one set of conditions; the field is every visit under all of them.

  • No interactions in the lab. Lighthouse never clicks anything, so it cannot measure INP. TBT is a rough stand-in: heavy blocking during load usually means slow taps during load.
  • Devices and networks. Your users may be on old Android phones on a train; a lab profile is one guess at a typical device.
  • Cache state. Lighthouse loads with an empty cache. Many real visits have a warm cache, or are instant Back and Forward navigations restored from memory.
  • Behaviour after load. CLS from infinite scroll or a late chat widget, and a slow third click, only happen when a real person stays on the page.
  • Personalization. Logged-in pages, A/B test variants, and cookie banners in some countries only exist in the field.
A matrix comparing CrUX, your own RUM, and Lighthouse. Measures INP: CrUX yes, RUM yes, Lighthouse no, it shows TBT instead. Real devices: yes, yes, no. Browsers: Chrome only, most browsers, one emulated run. Says why: no, yes, yes. How fresh: a 28-day window, minutes, each run. Works before launch: no, no, yes.CrUXyour RUMLighthousemeasures INPyesyesno, TBTreal devicesyesyesnobrowsersChromemostone runsays whynoyesyeshow fresh28 daysminuteseach runbefore launchnonoyes
No single column is all green. Field sources tell you what users feel; the lab tells you why and works before you have traffic.

Decision Where should a team get its Core Web Vitals numbers?

CrUX only (PageSpeed Insights, Search Console)

Look up the origin and key URLs in PageSpeed Insights or the CrUX API

Watch the Core Web Vitals report in Search Console

Pros

  • Free and needs no code
  • It is the data Google Search uses
  • Comparable with competitors' sites

Cons

  • Chrome only, opted-in users only
  • 28-day window: a regression shows slowly and a fix takes weeks to show fully
  • No data for low-traffic pages, and no detail on why

Your own RUM with web-vitals

Add the library, send each metric with attribution to your backend

Store per page view; chart p75 by route, device, and release

Pros

  • Real-time, every page, every supporting browser
  • Attribution says which element or script is to blame
  • Can tie vitals to business metrics like conversion

Cons

  • You build and pay for the pipeline
  • Numbers will not match CrUX exactly (different users, browsers, window)
  • Sampling and bot filtering need care

Lab tests in CI

Run Lighthouse on key pages on every pull request

Fail the build when a budget is crossed

Pros

  • Catches regressions before users see them
  • Repeatable and easy to debug
  • Works before launch, with zero traffic

Cons

  • No INP, only TBT
  • One device and network profile
  • Can pass while real users fail

What we pick: Use all three, for different jobs. CrUX is the scoreboard that Search uses. Your own RUM is the dashboard you act on, sliced by route and release, with attribution. Lab tests in CI are the guard rail that stops a regression from shipping. If you can only start with one, start with RUM on your top pages: it measures what users feel and tells you why.

Check your understanding · Exercise 6

Your Lighthouse LCP is 1.6 s on the product page, but CrUX shows p75 LCP of 3.2 s on phones. Which explanations are plausible? Select all that apply.

Why the 75th percentile

Every visit gets its own LCP, INP, and CLS. A page with 100,000 visits a month has 100,000 LCP values, from 0.6 s on fibre to 14 s on a weak rural signal. To judge the page you need one number, and Core Web Vitals use the 75th percentile, written p75: the value that 75% of visits are at or below.

How p75 is computed

  1. Collect the metric for every visit in the period, for one page or origin and one device type.
  2. Sort the values from fastest to slowest.
  3. Take the value three quarters of the way along the list. With 1,000 visits, it is the 750th value.
  4. Compare it with the threshold. p75 LCP of 2.3 s passes; 2.6 s does not.

Read p75 as a promise: a p75 LCP of 2.4 s means at least three out of four visits saw the main content within 2.4 s. It says nothing about the remaining quarter, which is why teams serious about performance also watch p95.

Why not the average or the median?

  • The average is pulled around by a few extreme visits: one 60-second visit from a train tunnel adds as much as 60 one-second visits. Worse, a fine average can hide a large group of slow users, such as everyone on a cheap phone.
  • The median, p50, only promises that half of your users are fine. A page where half the visits take 6 s can still have a good median.
  • A high percentile like p99 is dominated by things you cannot fix, such as dead connections and tabs opened and immediately hidden. It would be noisy and discouraging.

p75 sits in between. It insists that most visits are good, including a fair share of slower devices and networks, while ignoring the far tail that no team can fix.

Visits from slow phones:
LCP of 40 visits as dots on a scale from 0 to 6 seconds, with 20% of visits on slow phones. The median is 1.9 seconds and the 75th percentile is 2.1 seconds, against a good limit of 2.5 seconds.2.5 s0 s1 s2 s3 s4 s5 s6 smedianp75
Each dot is one visit. The 75th percentile is under 2.5 s, so this page passes LCP, even though some visits are slow.
Summary of 40 visitsLCPRating
Median (p50)1.9 sgood
Average2.2 sgood
75th percentile (p75)2.1 sgood

Play with the explorer. Move more of the audience onto slow phones and watch p75 cross the 2.5 s line long before the median moves much. This is the usual story for sites with growing traffic from emerging markets: the median looks stable while the vital quietly fails.

Segment before you judge

A p75 over "all visits" mixes populations that behave very differently. CrUX always reports phones and desktops separately, and so should your own dashboards. Other useful splits: route or page type (home, search, product, checkout), country, connection type, new versus returning visitor, and logged in versus not.

Segmenting often finds the real problem. A site whose overall p75 LCP is 2.6 s may be 1.8 s everywhere except product pages on Android, at 4.1 s. That points straight at the product image and the device class, rather than at "the site is slow".

Check your understanding · Exercise 7

A page has these LCP values from 8 phone visits: 1.2, 1.4, 1.6, 1.9, 2.2, 2.7, 3.5, 6.0 s. Which statements are true? Select all that apply.

In production: traps and real products

The definitions are simple; real sites are not. These are the situations that surprise teams most.

Single-page apps and soft navigations

In a single-page app, clicking a link changes the URL with the History API without loading a new document. The browser calls this a soft navigation. The standard metrics only know about real page loads: LCP is measured once, for the first page, and INP and CLS keep collecting across every route the user visits, all reported against the URL where the visit started.

Two effects follow. Your landing pages carry the INP and CLS of everything users do afterwards, and route changes that feel slow never show up as LCP at all. Since Chrome 151, Chromium measures soft navigations itself (the Real-user monitoring lesson shows how to report them), but other browsers don't yet, so also track route-change timing yourself in RUM (for example, from the click to the moment the new view's main content renders).

Other traps

  • Anti-flicker snippets. Some A/B testing tools hide the whole page with CSS until their script picks the variant. Every visit pays that wait as LCP render delay, up to the snippet's timeout, which can be several seconds.
  • Third-party tags. Tag managers, chat widgets, and ad scripts run long tasks during loading, exactly when users first tap. They are a top cause of poor INP, and they change without a deploy on your side.
  • Hydration. A server-rendered page looks ready before its JavaScript has attached event handlers. Taps during hydration wait behind it, or do nothing at all. Smaller bundles, partial hydration, and islands all shorten that window.
  • The back/forward cache. When the browser restores a page from memory on Back, LCP is almost instant. Pages with unload handlers are often not eligible, so they lose these free fast visits.
  • Iframes. Layout shifts inside a cross-origin iframe, such as an embedded video or ad, count towards the page's CLS in CrUX, but your own JavaScript cannot see them. Field CLS higher than your RUM CLS is a hint.

How real products handle it

  • Vodafone ran an A/B test on a landing page and published it on web.dev: a 31% better LCP came with 8% more sales. Case studies like this are why product teams fund vitals work.
  • Next.js reports vitals through a useReportWebVitals hook, and its Image component requires a size (or a fill container), so images reserve their space, and lets you mark the hero image as high priority.
  • Angular's NgOptimizedImage directive warns in development when the LCP image is not marked priority.

A triage flow

When a vital is bad, the path from symptom to fix is short if you take it in order: which vital, which part of it, and which lesson in this module has the fix.

A flowchart. Start: which vital fails at the 75th percentile? If LCP, split it into four parts: slow TTFB points to the CDN, an image found late points to the server HTML, a big file to the images lesson, and render delay to CSS and fonts. If INP, split it into three phases: waiting points to long tasks, a slow handler to yielding, a slow paint to a smaller DOM, all covered in runtime performance. If CLS, find what moved: images need sizes, ads need a min-height, fonts need size-adjust, and inserted content goes below the viewport.Which vital fails at p75?LCPsplit into 4 partsslow TTFB: CDNfound late: HTMLbig file: imagesrender: CSS, fontsINPsplit into 3 phaseswaits: long taskshandler: yieldpaint: small DOMsee runtime perfCLSfind what movedimages: sizesads: min-heightfonts: size-adjustinserts: below
Always split before you fix. The part that is biggest decides which lesson in this module you need next.

Check your understanding · Exercise 8

Search Console says INP is poor on your landing page, a single-page app's home route. Your RUM shows interactions on the home route itself are fast. What could explain it? Select all that apply.

Using Core Web Vitals in a system design interview

In a frontend system design interview, the non-functional requirements almost always include "it should be fast". Core Web Vitals turn that vague line into targets you can design for and measure. Say them early, then return to them when you make choices.

When to bring them up

  • In the requirements. "I'll target good Core Web Vitals at p75 on mid-range phones: LCP under 2.5 s, INP under 200 ms, CLS under 0.1." This frames every later decision.
  • When you choose a rendering strategy. Server rendering helps LCP; hydration cost threatens INP. Name both sides.
  • When you draw the page. Point at the LCP element. Say how it is delivered and how its space is reserved.
  • When you design an interaction-heavy part, such as a search box, a feed, or an editor: say how you keep each interaction under 200 ms.
  • At the end, under monitoring. RUM with web-vitals, p75 by route and device, alerts on regressions by release.

What a strong answer sounds like

For a product page: "The LCP element is the main product photo. It goes in the server HTML as a real img with fetchpriority high, a srcset of AVIF sizes, and fixed dimensions so it can't shift. The HTML is cached at the CDN for anonymous users so TTFB stays under 800 ms."

Then: "Reviews and recommendations load below the fold, in reserved space, so they don't hurt CLS. Add to cart updates the button state first and sends the request after the paint, so INP stays low even on slow phones." Each sentence ties a mechanism to a metric, which is what separates a performance answer from "we'll make it fast with lazy loading".

How deep to go, by level

LevelWhat the interviewer expects
JuniorNames LCP, INP, and CLS with their thresholds, and one cause and fix for each: image size for LCP, long tasks for INP, image dimensions for CLS.
Mid-levelSplits LCP into its four parts and INP into its three phases; knows field from lab and why Lighthouse cannot measure INP; designs the LCP element's delivery.
SeniorSets p75 targets per device class, plans RUM with attribution and release tracking, weighs rendering choices against LCP and INP together, and handles soft navigations, third parties, and regressions in CI.

Think first

You are designing an infinite news feed with ads every 5 posts. Which vital is most at risk, and what do you design for it?

Summary

  • Core Web Vitals are three field metrics, each tied to one thing users feel: LCP for loading (good at 2.5 s or less), INP for responsiveness (200 ms or less), and CLS for visual stability (0.1 or less).
  • A page passes when all three are good at the 75th percentile of real visits, judged separately for phones and desktops.
  • Split LCP into TTFB, load delay, load duration, and render delay, and fix the biggest part. The two delays are pure waste.
  • INP replaced FID in March 2024. It is the slowest interaction of the visit (ignoring one per 50), from input to the next paint: input delay, processing, and presentation. Paint first, then yield, then do the rest.
  • A layout shift scores impact fraction times distance fraction; CLS is the worst session window of shifts. Reserve space for anything that arrives late and animate with transform.
  • TTFB and FCP are not vitals, but the gaps between TTFB, FCP, and LCP tell you whether to look at the server, the render-blocking resources, or the main content.
  • Field data (CrUX, your own RUM) tells you what users feel; lab data (Lighthouse, DevTools) tells you why and guards CI. Lab tests cannot measure INP.

In the interview

Questions interviewers ask about this. Answer each out loud first, then compare.

Question 1

Your product page's p75 LCP on phones is 4.1 s. Walk me through how you would fix it.

Question 2

Lighthouse gives a page 98, but Search Console says its INP is poor. How can both be true?

Question 3

Why are Core Web Vitals judged at the 75th percentile rather than the average?

Question 4

What is the difference between FID and INP, and why did the change matter?

Question 5

An ad slot's height is not known until the ad loads. How do you stop it from hurting CLS?

Practice it

These design problems lean on core web vitals.

References

Read next