Core Web Vitals Measurement

This guide covers how to measure the three Core Web Vitals — LCP, INP, and CLS — in both the lab and the field, which phase of the rendering pipeline each one reflects, and how to capture them with the web-vitals approach built on PerformanceObserver. This topic is part of Rendering Performance Metrics and Tooling; read that first for the lab-versus-field distinction these metrics depend on.

Which Phase Each Vital Reflects

A vital is not an abstract score — each one is a stopwatch on a specific stretch of the rendering pipeline. Knowing which phase a metric measures tells you which section’s optimizations will move it.

Vital Measures Pipeline phase Entry type
LCP Time to largest paint Critical render path largest-contentful-paint
INP Gesture → next paint Input + layout/paint event, first-input
CLS Visual instability Layout layout-shift

LCP is dominated by the Critical Rendering Path Optimization work — render-blocking stylesheets and late image discovery delay the largest paint. INP is gated by main-thread availability when input arrives, so it is sensitive to the Forced Synchronous Layouts and long tasks that block the thread. CLS is pure layout instability, the domain of Layout and Paint Optimization.

Each Core Web Vital maps onto a rendering pipeline phase LCP measures the critical render path, INP measures input through layout and paint, CLS measures layout stability. Pipeline phase Critical render path Input + layout/paint Layout stability LCP INP CLS

Capturing the Vitals in the Field

The web-vitals approach wraps PerformanceObserver with the per-metric quirks each vital needs — taking the last LCP candidate before interaction, summing layout shifts into session windows, and reporting the worst interaction for INP. You can write the same logic directly on the observer.

// ❌ Reading a single entry gives the wrong number for every vital
const lcp = performance.getEntriesByType('largest-contentful-paint')[0]
report('LCP', lcp.startTime) // first candidate, not the final largest paint

// ✅ Observe each type, apply the vital's own reduction rule
function onLCP(cb) {
  let last = 0
  const po = new PerformanceObserver((list) => {
    const entries = list.getEntries()
    last = entries[entries.length - 1].startTime // keep the latest candidate
  })
  po.observe({ type: 'largest-contentful-paint', buffered: true })
  // LCP finalizes on first interaction or page hide
  addEventListener('visibilitychange', () => cb(last), { once: true })
}

The non-negotiable detail is buffered: true. LCP and layout-shift entries are recorded during the earliest moments of load, before your script executes. Without buffering, the observer only sees entries created after observe() runs, and you lose the data that matters most. This is the unifying pattern explained in PerformanceObserver API Patterns, and it applies identically to LCP, INP, and CLS.

Why buffered:true is required on the observer Early LCP and layout-shift entries are recorded before the script runs; only a buffered observer replays them. Load timeline observe() runs LCP candidate layout-shift event (first tap) buffered:true replays these two seen live anyway Without buffering, both left-hand entries are lost forever

Reading the Trace

A single observer stream shows how the three vitals interlock around one slow frame:

[PerformanceObserver stream — page load + first tap]
largest-contentful-paint   startTime: 3180ms  — over 2.5s, render-blocking CSS
layout-shift (no input)    value: 0.14        — hero image had no width/height
event (pointerdown)        duration: 248ms    — INP candidate, over 200ms
├─ input delay                       150ms    — main thread in a long task
├─ processing                         70ms    — handler read layout synchronously
└─ presentation delay                 28ms    — frame missed the 16.6ms budget

Each line is a different vital, and they share root causes: the same long task that delayed input could be the script that injected the unsized image causing the shift. Measuring them in one stream is how you avoid fixing them in isolation.

Lab Measurement of the Same Vitals

In the field these come from real interactions; in the lab you script them. LCP and CLS surface in any Lighthouse run because they accrue during load. INP is the exception: it requires an actual interaction, so a default lab run reports nothing for it. To get a lab INP you must drive a tap or keypress — which is why INP regressions are best caught by scripted WebPageTest runs rather than a plain page-load audit. Reproduce the field number, then assert against it in CI.

Which vitals a plain page-load lab run can and cannot capture LCP and CLS accrue during load and surface in any lab run; INP needs a scripted interaction. Plain load audit Lighthouse default LCP captured CLS captured INP: no value Scripted tap / keypress WebPageTest replay in CI

Edge Cases and Framework Interactions

  • Single-page apps: soft navigations do not reset LCP or CLS automatically. Report and reset your accumulators on route change, or the second view inherits the first view’s metrics.
  • React / Vue hydration: the hydration pass commonly produces a long task that inflates INP for the first interaction and a layout shift if the server and client markup differ in size. Measure post-hydration, not just at first paint.
  • bfcache restores: a page restored from the back/forward cache fires no fresh paint entries. Listen for pageshow with persisted: true and report cached navigations separately.
Three lifecycle events that break naive vital measurement Soft navigations, hydration, and bfcache restores each require a specific handler to keep vitals accurate. SPA soft navigation LCP/CLS not reset Reset accumulators on route change Hydration pass long task inflates INP Measure post-hydration not at first paint bfcache restore no fresh paint entries Handle pageshow persisted:true branch

Going Deeper

Two of these vitals have dedicated APIs with their own attribution data. For INP, the Event Timing API exposes interactionId and the three-phase breakdown — see Measuring INP with the Event Timing API. For CLS, the Layout Instability API attributes each shift to the nodes that moved — see Debugging CLS with the Layout Instability API.

Metric Targets

Metric Target (p75) Measurement method
LCP < 2.5s largest-contentful-paint, last candidate
INP < 200ms max event duration per interaction
CLS < 0.1 summed layout-shift per session window
Lab parity within p75 band scripted WebPageTest replay

Confirm a fix only when the field p75 crosses the threshold; a single green lab run can mask a regression that only real interaction patterns trigger.

Measuring Each Vital in the Field

The three Core Web Vitals are all measurable in real users’ browsers through the PerformanceObserver API, and the field number is the one that counts because it reflects the devices, networks, and thermal conditions your users actually have. LCP is reported by observing largest-contentful-paint entries; the last one before the first user interaction is the value, and its element, url, and timing fields tell you what the largest paint was and why it was late. CLS is the sum of layout-shift entries that were not preceded by recent input, grouped into session windows; each entry carries the sources that moved, so you can attribute a shift to a specific element. INP is derived from event timing entries with an interactionId, taking a high percentile of interaction latencies across the whole session rather than a single worst case.

The reason to instrument these yourself rather than rely solely on lab tools is that lab and field routinely disagree, and the disagreement is informative. A page can pass every Lighthouse run on a fast desktop profile and still fail CLS in the field because real users load it on slow connections where images arrive after first paint. Collecting field data with the web-vitals library or hand-rolled observers, and reporting the 75th percentile that Google grades against, turns “it looks fine locally” into “it is fine for three-quarters of real sessions.” The observer patterns for each entry type are detailed in PerformanceObserver API patterns.

// Field LCP: the last largest-contentful-paint entry before first interaction.
new PerformanceObserver((list) => {
  const entries = list.getEntries()
  const last = entries[entries.length - 1]
  reportLCP(last.startTime, last.element?.tagName, last.url)
}).observe({ type: 'largest-contentful-paint', buffered: true })

Attributing a Bad Number to a Cause

A metric is only useful once it points at a fix, and each Vital decomposes into attributable parts. A slow LCP breaks into time-to-first-byte, resource load delay, resource load time, and element render delay; a large render-delay share means the element was discovered or unblocked late (a render-blocking stylesheet, a lazy hero, or client-side rendering), while a large load-time share means the image bytes are simply big or slow. A high CLS attributes to the specific sources in each layout-shift entry — usually an image or ad without reserved space, or a late web-font swap that re-measures text. A high INP attributes to one of three phases of the offending interaction: input delay, processing time, or presentation delay, each pointing at a different fix.

This attribution is what separates a productive performance investigation from guesswork. Two pages with an identical failing INP can need opposite fixes — one is losing time to input delay from an unrelated long task and needs task-chunking, the other to presentation delay from a forced reflow in its handler and needs read/write batching. Without attribution you would try both and learn nothing; with it, the field entry tells you which. The deep dives on measuring INP with the Event Timing API and debugging CLS with the Layout Instability API carry each of these attributions to a concrete fix.

Common Measurement Pitfalls

Several mistakes recur often enough to name. Reporting an average instead of a p75 hides the slow tail that Google grades and that users feel — the distribution is heavy-tailed, so the mean can look healthy while the 75th percentile fails. Measuring only on a fast device understates every metric, because the field distribution includes mid-tier and low-end hardware where the same page runs several times slower. Stopping LCP measurement too early misses a later, larger paint; the value is not final until the first interaction. And treating CLS as a single number rather than inspecting its sources means you never learn which element to fix. Avoiding these is mostly a matter of discipline: collect the full distribution, report the percentile Google uses, sample across real hardware, and always keep the per-entry attribution so the number stays actionable rather than merely alarming.

From Field Data to a Monitored Budget

Collecting Vitals is only half the job; the value comes from wiring them into something that alerts when a release regresses. The durable pattern is to send each metric — LCP, CLS, and INP, along with its attribution — to an analytics endpoint keyed by route and device class, then compute the p75 per route on a rolling window. A route whose p75 crosses its threshold becomes an alert with the attribution already attached, so the on-call engineer sees not just “LCP regressed on the product page” but “LCP regressed on the product page, render-delay share up 400ms” — a starting point rather than a mystery. Segmenting by device class is what keeps a regression on low-end hardware from being averaged away by fast desktops.

Pair that field monitoring with a lab gate in CI so regressions are caught before they ship, not just after. The lab proxy — Total Blocking Time for INP, lab LCP, and CLS in a throttled Lighthouse run — fails the build fast and reproducibly, while the field p75 remains the real verdict on user experience. Setting the CI tolerance from the field distribution rather than a round number gives you headroom: if field LCP p75 sits at 2.1s against a 2.5s target, a build that fails at 2.3s catches the regression while there is still margin. The mechanics of both halves live in lab tooling and CI, and the observer plumbing that feeds the field side is in PerformanceObserver API patterns. The loop that ties the whole section together is simple to state and hard to maintain without tooling: measure each Vital in the field, attribute the number to a pipeline stage, fix that stage, and encode the win as both a lab gate and a field alert so it cannot silently reverse. A Vital you only look at when something feels slow is a diagnostic; a Vital you monitor per route and gate in CI is a guarantee, and the difference between the two is usually the difference between catching a regression in a code review and hearing about it from a ranking drop weeks later. Because the Vitals are a ranking signal as well as a user-experience one, that lag is not just an engineering cost but a business one, which is the argument that usually justifies the monitoring investment to a sceptical stakeholder.

Frequently Asked Questions

Why does buffered:true matter for LCP and CLS but feel optional for INP?

LCP candidates and layout-shift entries are emitted during the earliest moments of load, usually before your measurement script parses and runs. Without buffered: true the PerformanceObserver only sees entries created after observe() is called, so those early records are lost. INP is driven by interactions that almost always happen after your script is live, so buffering matters less there — but keep it on for consistency across all three vitals.

Why does a default Lighthouse run report no INP?

INP measures a real gesture through to the next paint, and a plain page-load audit performs no gesture. LCP and CLS accrue during load so they surface automatically, but INP stays empty until you script a tap or keypress. Drive the interaction with a scripted WebPageTest run, then assert against the field p75 in CI.

Which vital should I fix first when all three regress together?

Trace them in one PerformanceObserver stream and look for the shared root cause. A single long task frequently delays input (raising INP), and the script inside it can inject an unsized image (raising CLS) while blocking the largest paint (raising LCP). Fixing the long task or the render-blocking resource often moves all three at once, so start from the pipeline phase rather than the score.

How do I keep vitals accurate in a single-page app?

Soft navigations do not reset LCP or CLS, so the second route inherits the first view’s metrics. Report and reset your accumulators on every route change, measure INP after hydration completes rather than at first paint, and branch on pageshow with persisted: true to report back/forward-cache restores separately.