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.
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.
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.
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
paintentries. Listen forpageshowwithpersisted: trueand report cached navigations separately.
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.