Module 06: Web Vitals

In Module 05 we measured how fast the page loaded using the Navigation Timing API. Now we measure how good the experience was from the user's perspective. Google's Core Web Vitals — LCP, CLS, and INP — quantify visual completeness, visual stability, and responsiveness. Unlike navigation timing, which you poll after load, these metrics arrive asynchronously via PerformanceObserver.

Demo Files

Run: Open test.html, interact with the page, and watch vitals appear in real time.

What Are Core Web Vitals?

Google's Core Web Vitals are three metrics that measure real user experience:

Each metric has defined thresholds that classify user experience as good, needs improvement, or poor:

Metric Good Needs Improvement Poor
LCP ≤ 2.5s ≤ 4.0s > 4.0s
CLS ≤ 0.1 ≤ 0.25 > 0.25
INP ≤ 200ms ≤ 500ms > 500ms

These thresholds are based on Google's analysis of user behavior: when LCP exceeds 4 seconds, bounce rates increase dramatically. When CLS exceeds 0.25, users report frustration with "jumpy" pages. When INP exceeds 500ms, users perceive the page as unresponsive.

PerformanceObserver: Event-Driven Metrics

Unlike Navigation Timing (which you poll after load), Web Vitals arrive asynchronously via PerformanceObserver. You subscribe to performance entry types and receive callbacks as entries are recorded by the browser. This is fundamentally different from the "read it once after load" approach we used in Module 05.

const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    console.log(entry.entryType, entry);
  }
});
observer.observe({ type: 'largest-contentful-paint', buffered: true });

The key parameter is buffered: true, which retrieves entries that were recorded before the observer was created. Without it, you would miss any entries that occurred between page load start and the moment your observer script runs.

Each performance entry type has its own shape. The largest-contentful-paint entry has renderTime and loadTime. The layout-shift entry has value and hadRecentInput. The event entry has duration and interactionId. The observer pattern lets you subscribe to exactly the entries you need.

Measuring LCP

let lcpValue = 0;

function observeLCP() {
  const observer = new PerformanceObserver((list) => {
    const entries = list.getEntries();
    const lastEntry = entries[entries.length - 1];
    // LCP uses renderTime if available, otherwise loadTime
    lcpValue = lastEntry.renderTime || lastEntry.loadTime;
  });
  observer.observe({ type: 'largest-contentful-paint', buffered: true });
  return observer;
}

LCP fires multiple times as progressively larger elements render on the page. The browser first identifies a small text node as the "largest" element, then updates that as a hero image loads and becomes the new largest element. The last entry emitted before the user interacts with the page is the final LCP value.

The renderTime property is preferred because it measures when the element was actually painted to screen. However, renderTime may be 0 for cross-origin images that do not include the Timing-Allow-Origin response header. In that case, we fall back to loadTime, which measures when the resource finished downloading (slightly less accurate, but still useful).

Cross-Reference: Compare to the reference collector.js initLargestContentfulPaint() (line 278): same pattern — track the last entry's renderTime || loadTime.

Measuring CLS

let clsValue = 0;

function observeCLS() {
  const observer = new PerformanceObserver((list) => {
    for (const entry of list.getEntries()) {
      // Only count shifts without recent user input
      if (!entry.hadRecentInput) {
        clsValue += entry.value;
      }
    }
  });
  observer.observe({ type: 'layout-shift', buffered: true });
  return observer;
}

Layout shifts are accumulated over the page's lifetime. Each layout-shift entry has a value property that represents the fraction of the viewport that shifted. The hadRecentInput filter is critical: it excludes shifts caused by user actions. When a user clicks a button that expands an accordion, the resulting layout shift is expected and should not count against CLS. Only unexpected shifts — those caused by late-loading images, injected ads, or dynamically inserted content — are penalized.

CLS is the sum of all unexpected shift values. A page with a CLS of 0 had no unexpected layout movement at all. A CLS of 0.1 means roughly 10% of the viewport shifted unexpectedly.

Note: The CLS spec has evolved. The current definition uses "session windows" — groups of shifts within 1 second, with up to 5 seconds between windows. The maximum session window score is the CLS value. Our simplified version (sum all shifts) is close enough for educational purposes.
Cross-Reference: Compare to collector.js initLayoutShift() (line 289): same pattern — accumulate value from entries where !hadRecentInput.

Measuring INP (Interaction to Next Paint)

let inpValue = 0;

function observeINP() {
  const interactions = [];

  const observer = new PerformanceObserver((list) => {
    for (const entry of list.getEntries()) {
      // event entries with interactionId represent user interactions
      if (entry.interactionId) {
        interactions.push(entry.duration);
      }
    }
    // INP is the worst interaction (simplified)
    // The actual algorithm uses the 98th percentile
    if (interactions.length > 0) {
      interactions.sort((a, b) => b - a);
      inpValue = interactions[0];
    }
  });
  observer.observe({ type: 'event', buffered: true, durationThreshold: 16 });
  return observer;
}

INP replaced FID (First Input Delay) in March 2024. While FID only measured the first interaction's input delay, INP measures all interactions throughout the page's lifetime and reports the worst one (technically the 98th percentile, but for simplicity we use the maximum). This makes INP a much better indicator of overall page responsiveness.

The observer uses type: 'event' with durationThreshold: 16, which captures interactions taking more than one frame (16ms at 60fps). Each event entry with a non-zero interactionId represents a distinct user interaction (click, keypress, tap). The duration property measures the full round-trip from input to next paint.

Note: The reference collector.js still uses FID (initFirstInputDelay, line 252) — it was written before INP became a Core Web Vital. Our version uses the modern INP metric, which provides a more complete picture of page responsiveness.

Scoring Function

const thresholds = {
  lcp: [2500, 4000],
  cls: [0.1, 0.25],
  inp: [200, 500]
};

function getVitalsScore(metric, value) {
  const t = thresholds[metric];
  if (!t) return null;
  if (value <= t[0]) return 'good';
  if (value <= t[1]) return 'needsImprovement';
  return 'poor';
}

The scoring function maps raw metric values to the three-tier classification. This is useful both for display (color-coding the values on a dashboard) and for analytics (filtering to find pages or user segments with poor experience).

Cross-Reference: This is the same pattern as collector.js getVitalsScore() (line 77), but updated with current threshold values.

Multiple Beacons Per Page Load

Unlike previous modules where we sent one beacon on page load, Web Vitals require a different strategy. The final values for CLS and INP are not known until the user leaves the page. LCP finalizes when the user first interacts. This means we need multiple beacons per page load:

  1. Initial beacon on load — timing data, technographics, resources (same as Module 05)
  2. LCP beacon — fires after the largest paint, before user interaction
  3. CLS final beacon — fires on page hide with accumulated shift values
  4. INP beacon — fires on page hide with the worst interaction duration

The visibilitychange event with document.visibilityState === 'hidden' is the most reliable way to capture final vital values. It fires when the user switches tabs, minimizes the browser, or navigates away:

// Send vitals on page hide (most accurate final values)
document.addEventListener('visibilitychange', () => {
  if (document.visibilityState === 'hidden') {
    sendVitals();
  }
});

function sendVitals() {
  const vitals = {
    lcp: { value: round(lcpValue), score: getVitalsScore('lcp', lcpValue) },
    cls: { value: round(clsValue * 1000) / 1000, score: getVitalsScore('cls', clsValue) },
    inp: { value: round(inpValue), score: getVitalsScore('inp', inpValue) }
  };
  send({
    type: 'vitals',
    vitals: vitals,
    url: window.location.href,
    timestamp: new Date().toISOString()
  });
}

Notice that CLS values are rounded to three decimal places (round(clsValue * 1000) / 1000) because CLS is a unitless fraction, not milliseconds. LCP and INP are rounded to the nearest millisecond.

Integrating with the Collector

Our collector-v5.js extends the previous collector by starting all three observers when the script loads, sending the initial beacon on the load event (with timing and technographics as before), and sending a separate vitals beacon on visibilitychange when the page is hidden:

// Start observers immediately (they buffer past entries)
const lcpObserver = observeLCP();
const clsObserver = observeCLS();
const inpObserver = observeINP();

// Initial beacon: timing + technographics (same as v4)
window.addEventListener('load', () => {
  setTimeout(() => {
    collect();
  }, 0);
});

// Vitals beacon: final values when page is hidden
document.addEventListener('visibilitychange', () => {
  if (document.visibilityState === 'hidden') {
    sendVitals();
  }
});

The observers are created immediately (outside any event listener) so they can capture entries from the very beginning of the page load. The buffered: true option ensures no entries are lost even if the observer is created slightly late.

Summary