Skip to content

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, priority it, 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: auto on 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 startTransition and 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.

layout shift score = impact fraction × distance fraction
  • 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

  1. Add web-vitals/attribution to your app (implementation in 9.1; a copy‑paste reporter is in examples/rum/).
  2. Send name, value, rating, page_type, device_class, connection, and the full attribution object.
  3. Build three dashboard views:
  4. p75 per metric per page type, mobile vs desktop, trended daily
  5. LCP sub‑part breakdown (TTFB / load delay / load time / render delay) as a stacked bar
  6. Top 10 INP interactionTarget selectors by count × latency
  7. 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

Next: 1.4 Measuring: lab vs field