v139 · Core Web Vitals Dashboard

Core Web Vitals Dashboard

Each SoftNavigationEntry carries a navigationId. LCP, INP, and CLS entries on the same route share that ID — enabling accurate, per-route Web Vitals for SPAs instead of one aggregate number for the whole session.

Simulated data. This dashboard generates realistic vitals entries linked by navigationId. In production, collect from real PerformanceObserver callbacks across all entry types.

Simulate navigating through a SPA. Each button triggers a new soft navigation with its own LCP, INP, and CLS measurements.

Soft navigation vitals timeline

Click a route above to simulate a soft navigation…

navigationId correlation — how entries link across types
Soft navigation
entryType: 'soft-navigation'
name: /products
navigationId: nav-2-x4k9r
duration: 142ms
⟵ navigationId ⟶
Correlated vitals
entryType: 'largest-contentful-paint'
startTime: 118ms
navigationId: nav-2-x4k9r
entryType: 'event' (INP candidate)
processingStart: 23ms, duration: 48ms
navigationId: nav-2-x4k9r
entryType: 'layout-shift'
value: 0.04
navigationId: nav-2-x4k9r
Good Needs improvement Poor LCP thresholds: ≤2.5s good, ≤4s NI  |  INP: ≤200ms good, ≤500ms NI  |  CLS: ≤0.1 good, ≤0.25 NI

API code

// Collect all vitals entries keyed by navigationId
const vitalsByNav = new Map(); // navigationId → { lcp, inp, cls }

function getOrCreate(navId) {
  if (!vitalsByNav.has(navId)) {
    vitalsByNav.set(navId, { lcp: null, inp: null, cls: 0 });
  }
  return vitalsByNav.get(navId);
}

// SoftNavigation entries — one per route change
new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    console.log(`Soft nav to ${entry.name} — id: ${entry.navigationId}`);
    getOrCreate(entry.navigationId).url = entry.name;
  }
}).observe({ type: 'soft-navigation', buffered: true });

// LCP — attributable to a navigation via navigationId
new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    const vitals = getOrCreate(entry.navigationId);
    vitals.lcp = entry.startTime; // overwrite until navigation ends
  }
}).observe({ type: 'largest-contentful-paint', buffered: true });

// INP — event timing entries carry navigationId in Chrome 147+
new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    const vitals = getOrCreate(entry.navigationId);
    const dur = entry.processingEnd - entry.processingStart + entry.duration;
    if (!vitals.inp || dur > vitals.inp) vitals.inp = dur;
  }
}).observe({ type: 'event', durationThreshold: 16, buffered: true });

// CLS — accumulate layout shifts per navigation window
new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (!entry.hadRecentInput) {
      const vitals = getOrCreate(entry.navigationId);
      vitals.cls = (vitals.cls || 0) + entry.value;
    }
  }
}).observe({ type: 'layout-shift', buffered: true });

see also