1.3 — Core Web Vitals, precisely¶
Module 1 · Lesson 3 · 🟢 Foundational · ~30 min
What you'll learn¶
- Exact definitions of LCP, INP, and CLS — including the edge cases that bite commerce sites
- The diagnostic sub‑parts of each metric (this is what makes them actionable)
- Metrics CWV doesn't cover that matter for commerce, and how to define your own
- Why p75 and why field data
The three, at a glance¶
| Metric | Measures | Good | Needs work | Poor |
|---|---|---|---|---|
| LCP — Largest Contentful Paint | Loading: when the main content appeared | ≤ 2.5 s | ≤ 4.0 s | > 4.0 s |
| INP — Interaction to Next Paint | Responsiveness: worst‑ish interaction latency | ≤ 200 ms | ≤ 500 ms | > 500 ms |
| CLS — Cumulative Layout Shift | Visual stability: unexpected movement | ≤ 0.1 | ≤ 0.25 | > 0.25 |
Thresholds are evaluated at the 75th percentile of page loads, segmented by device class (mobile and desktop are scored separately). INP replaced FID as a Core Web Vital in March 2024 — FID was easy to pass and told you almost nothing; INP is a far more honest measure of how a React app feels.
LCP — Largest Contentful Paint¶
Definition: the render time of the largest image or text block visible within the viewport, relative to when the page started loading. The browser keeps updating the candidate as content loads, and finalizes at the first user interaction (scroll, keypress, tap) or when the page is hidden.
What counts as an LCP candidate: <img>, <image> inside <svg>, <video> poster frames,
elements with a CSS background-image loaded via url(), and block‑level elements containing
text nodes. On a commerce PDP it's almost always the product image; on a PLP it's the hero banner
or the first product tile; on checkout it's often a heading or the form container.
The four sub‑parts (this is the diagnostic key)¶
LCP decomposes into four intervals. Every fix targets one of them, and knowing which one turns "LCP is bad" into a one‑line diagnosis:
|--- TTFB ---|--- Resource load delay ---|--- Resource load time ---|--- Render delay ---|
0 1270ms 1450ms 2720ms 2900ms
47% 6% 44% 6%
| Sub‑part | Means | Typical cause | Fix in |
|---|---|---|---|
| TTFB | Time to first byte | Dynamic rendering, waterfalls, cold starts, no CDN cache | 6.4, 3.4 |
| Resource load delay | HTML arrived → LCP resource started downloading | Image not in HTML, no priority, discovered by JS, low fetch priority |
2.1 |
| Resource load time | Download duration | Image too large, wrong format, no CDN, wrong sizes |
2.1 |
| Render delay | Downloaded → painted | Render‑blocking CSS/JS, font blocking, client‑side gating | 2.5 |
Get this breakdown in the field with the web-vitals attribution build:
import { onLCP } from 'web-vitals/attribution';
onLCP(({ value, attribution }) => {
console.log({
lcp: value,
element: attribution.element, // CSS selector of the LCP element
url: attribution.url, // resource URL, if an image
ttfb: attribution.timeToFirstByte,
loadDelay: attribution.resourceLoadDelay,
loadTime: attribution.resourceLoadDuration,
renderDelay: attribution.elementRenderDelay,
});
});
Shipping this to your analytics is the single highest‑value hour in this course. Without it, "LCP is 4.6 s" is a number. With it, you know that 44% of it is one 340 KB JPEG being served at 2200px wide to a 390px viewport, and you fix it before lunch. Full implementation: 9.1 RUM.
Commerce edge cases¶
- Carousels. If the hero rotates, LCP is whichever slide was largest when it painted. A
carousel that auto‑advances before LCP finalizes can produce wild variance. Server‑render slide
1,
priorityit, and lazy‑load the rest. - Skeletons don't help LCP. A gray box isn't contentful. Skeletons improve perceived speed and prevent CLS; they don't move LCP. Don't let a skeleton project be sold as an LCP project.
- Above‑the‑fold only. An enormous image below the fold is irrelevant to LCP. Aggressively lazy‑load everything below the fold — but never the LCP element.
content-visibility: autoon an above‑fold container can delay LCP. Use it below the fold only.- SPA navigations don't produce a new LCP in the standard metric. Chrome has been developing soft‑navigation heuristics, but as of now, treat client‑side route changes as unmeasured by default and add your own timing. See 9.1.
INP — Interaction to Next Paint¶
Definition: measures the latency of all qualifying interactions (click/tap, keypress — not scroll or hover) throughout the page's lifetime, and reports approximately the worst one. Precisely: for pages with fewer than 50 interactions the value is the longest; above that, it discounts one interaction per 50, so a single fluke doesn't define the page.
Latency is measured from user input to the next frame painted — not to when the handler finished. This is the crucial part: work you do after the handler, or a React re‑render triggered by it, all counts.
The three sub‑parts¶
User taps "Add to bag"
│
├─ Input delay ─────┤ main thread was busy with something else (a tag, hydration, a render)
├─ Processing time ────────┤ your event handlers + React render + effects
├─ Presentation delay ──┤ style, layout, paint
▲ next frame on screen
| Sub‑part | Typical commerce cause | Fix |
|---|---|---|
| Input delay | Third‑party scripts, hydration, a long timer callback | 2.3, 4.4 |
| Processing | Re‑rendering 120 product cards, expensive context updates, synchronous analytics | 5.3, 5.5 |
| Presentation | Huge DOM, complex CSS, forced reflow, non‑composited animation | 5.4, 2.5 |
import { onINP } from 'web-vitals/attribution';
onINP(({ value, attribution }) => {
console.log({
inp: value,
target: attribution.interactionTarget, // selector of the element interacted with
type: attribution.interactionType, // 'pointer' | 'keyboard'
inputDelay: attribution.inputDelay,
processingDuration: attribution.processingDuration,
presentationDelay: attribution.presentationDelay,
loadState: attribution.loadState, // was the page still loading?
longAnimationFrames: attribution.longAnimationFrameEntries, // scripts responsible
});
});
longAnimationFrameEntries gives you script URLs and source locations for the work in the frame.
That's how you prove the chat widget is responsible without arguing about it.
Commerce edge cases¶
- INP is a whole‑page metric. One bad interaction late in a session ruins the score for that page view. A PLP where the 30th filter toggle takes 900 ms scores as 900 ms.
- Interactions during load count, and they're often the worst ones — a user taps a size selector while hydration is still running. Fixing hydration cost fixes INP.
- Navigation is an interaction. Tapping a product card and waiting for a client‑side route
transition counts until the next paint. This is why
startTransitionand instant loading UI matter for INP, not just for feel. - Scroll is not an interaction for INP, but scroll jank still ruins the experience and is measured by other means (long animation frames).
- Desktop INP is usually fine and mobile INP is usually not. Always look at them separately.
CLS — Cumulative Layout Shift¶
Definition: the sum of layout shift scores for unexpected shifts during the page's lifetime, where the reported value is the largest session window — a burst of shifts each within 1 s of the last, capped at 5 s total.
- Impact fraction: the share of the viewport affected by the moving elements (union of before and after positions).
- Distance fraction: the greatest distance any element moved, divided by viewport height/width.
Shifts within 500 ms of a user interaction are excluded — expanding an accordion is expected. This exclusion is generous and is why "the user clicked it" is often a valid answer.
The eight commerce sources of shift¶
| Source | Fix |
|---|---|
| Images without dimensions | width/height or aspect-ratio; next/image handles it |
| Web fonts swapping metrics | size-adjust fallbacks via next/font |
| Late promo/cookie banners inserted at top | Reserve space, or overlay instead of push |
| Ads and third‑party embeds | Fixed‑size containers sized to the largest expected creative |
| A/B test variants swapping in after paint | Server‑side or edge assignment, no client flicker |
| Client‑side price/stock hydration ("From $—" → "$89.00") | Render the value server‑side, or reserve width |
| Infinite scroll pushing the footer | Reserve space, avoid mid‑list insertion |
@font-face + dynamic content in the same container |
Combination of the above |
Full playbook: 6.3 CLS.
import { onCLS } from 'web-vitals/attribution';
onCLS(({ value, attribution }) => {
console.log({
cls: value,
largestShiftTarget: attribution.largestShiftTarget, // the actual culprit selector
largestShiftTime: attribution.largestShiftTime,
largestShiftValue: attribution.largestShiftValue,
loadState: attribution.loadState,
});
});
CLS is the cheapest vital to fix and the one that most damages trust. Most sites can get from 0.25 to under 0.05 in under a week. Do it early — it's a visible, uncontroversial win that buys you room for harder work.
Why p75, and why field data¶
Why the 75th percentile: it means three of four page views were at least this good. The mean hides a catastrophic tail (perf distributions are strongly right‑skewed — a few 15 s loads pull the average without showing up as a threshold failure). p95 is too noisy to drive a roadmap. p75 is the compromise, and it's what Chrome uses for scoring.
Why field over lab:
| Lab (Lighthouse, local trace) | Field (RUM, CrUX) | |
|---|---|---|
| Devices | One, usually simulated | Your actual user mix |
| Network | Simulated throttle | Real congestion, real carriers |
| Cache state | Always cold | Mixed warm/cold |
| Interactions | Synthetic or none | Real behavior — the only valid source for INP |
| Use it for | Debugging, CI gates, A/B of a change | Truth, targets, prioritization |
INP essentially cannot be measured in the lab. Lighthouse reports Total Blocking Time as a proxy, which correlates but is not the same thing — TBT can't know that your users' worst interaction is the size selector on a PDP. Always validate INP in the field.
Use both: field data tells you what is broken and for whom; lab tools tell you why.
What Core Web Vitals don't cover¶
CWV is a floor, not a strategy. For commerce, add these:
| Custom metric | Why | How |
|---|---|---|
| Time to Add‑to‑Cart Ready | The moment the buy button actually works. Users tap it long before "interactive" | performance.mark() after the button's handler is wired |
| Search results latency | Query submit → results painted | performance.measure() around the interaction |
| Filter apply latency | The PLP's core interaction | Same |
| Variant switch latency | Color/size change → image + price updated | Same |
| Checkout step transitions | Each step's TTI; abandonment is step‑specific | Same |
| Soft navigation LCP | Client‑side route changes aren't in standard LCP | Manual timing on route change |
| Error rate under load | A "fast" page that fails is worse than a slow one | Guardrail metric on every A/B |
| RSC payload size / JS per route | Leading indicators; they regress before the vitals do | CI, 9.2 |
// Custom timing: time-to-add-to-cart-ready
// In the client component that owns the buy button:
useEffect(() => {
performance.mark('atc-ready');
const nav = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming;
const value = performance.now() - nav.startTime;
reportMetric({ name: 'atc_ready', value, page: 'pdp' });
}, []);
Aurora's Time to Add‑to‑Cart Ready was 4.4 s while LCP was 4.6 s and Lighthouse said 71. The custom metric is what convinced the business the problem was real.
Common mistakes¶
| Mistake | Reality |
|---|---|
| Optimizing the Lighthouse score | It's a weighted lab composite. Field p75 is what's scored and what users feel |
| Reporting averages | Hides the tail where the money is |
| Mixing mobile and desktop | They're scored separately and have different bottlenecks |
| Treating CWV as the goal | They're proxies. The goal is revenue and user experience |
| Assuming skeletons improve LCP | They improve CLS and perception, not LCP |
| Only measuring the homepage | Your traffic is on PDP/PLP; the homepage is the easiest page you own |
| Trusting lab INP | Lab can't reproduce your users' real interaction patterns |
Lab 1.3 — Get attribution into your dashboard¶
- Add
web-vitals/attributionto your app (implementation in 9.1; a copy‑paste reporter is inexamples/rum/). - Send
name,value,rating,page_type,device_class,connection, and the full attribution object. - Build three dashboard views:
- p75 per metric per page type, mobile vs desktop, trended daily
- LCP sub‑part breakdown (TTFB / load delay / load time / render delay) as a stacked bar
- Top 10 INP
interactionTargetselectors by count × latency - Write down the single biggest sub‑part for each metric on your worst page. That's your Module 6 starting point.
Checklist¶
- Field data collected with the attribution build, not just the basic one
- Metrics segmented by page type and device class
- LCP element identity known per page type (you can name it)
- Top INP interaction targets known by selector
- At least two commerce‑specific custom metrics defined and collected
- Nobody on the team is quoting a Lighthouse score as a business metric