Real-Time Dashboard
Event Log
LCP — Largest Contentful Paint
LCP measures when the largest content element becomes visible. It re-fires as larger elements render. Try adding images or text blocks and watch the LCP metric update.
Trigger LCP Changes
INP — Interaction to Next Paint
INP measures the worst interaction latency. It captures the delay from user input to the next paint. Click buttons below that run expensive synchronous JavaScript to see INP increase.
Trigger Interactions
CLS — Cumulative Layout Shift
CLS measures unexpected layout shifts. Elements injected without reserved space cause shifts. Try injecting elements to see CLS accumulate.
Trigger Layout Shifts
Metric Timeline
Visual timeline showing when each metric event fires relative to page load.
Events Over Time
Before / After Comparison
Side-by-side comparison of unoptimized vs optimized patterns for each Core Web Vital.
LCP: Image Loading
✗ Unoptimized
Large image without dimensions, no priority hints. Browser discovers late, layout shifts during load.
<img src="hero.jpg">
✓ Optimized
Explicit dimensions, fetchpriority="high", preload link. Browser starts loading immediately.
<link rel="preload" as="image" href="hero.webp"> <img src="hero.webp" width="800" height="400" fetchpriority="high" decoding="async">
INP: Event Handlers
✗ Unoptimized
Heavy synchronous work in click handler blocks the main thread and delays paint.
button.onclick = () => {
// 500ms of synchronous work
const data = heavyComputation();
updateDOM(data);
};
✓ Optimized
Show immediate feedback, defer heavy work with requestIdleCallback or setTimeout.
button.onclick = () => {
button.textContent = 'Processing...';
// Yield to browser for paint
setTimeout(() => {
const data = heavyComputation();
updateDOM(data);
}, 0);
};
CLS: Dynamic Content
✗ Unoptimized
Ad or banner injected with no reserved space. Pushes content down unexpectedly.
<!-- Content loads, then ad injects above -->
<div id="ad"></div>
<main>Article content...</main>
<script>
// Ad loads later, 250px tall
document.getElementById('ad')
.innerHTML = '<img src="ad.jpg">';
</script>
✓ Optimized
Reserve space with min-height or aspect-ratio. Content stays in place.
<div id="ad" style="
min-height: 250px;
contain: layout;
"></div>
<main>Article content...</main>
<script>
// Ad fills reserved space, no shift
document.getElementById('ad')
.innerHTML = '<img src="ad.jpg"
width="728" height="250">';
</script>
Preset Scenarios
Click a scenario to automatically trigger a series of actions and see how metrics respond.
✅ Perfect Score
Minimal page with small, pre-sized content. All metrics stay green.
🖼 Heavy Images
Load multiple large images without optimization. LCP degrades.
⏳ Blocking JavaScript
Click handlers with expensive sync operations. INP spikes.
📢 Ad Injection Storm
Rapidly inject elements without reserved space. CLS accumulates.
🐢 Slow Page Load
Simulates a poorly optimized page: large LCP, layout shifts, heavy interactions.
🔄 SPA Navigation
Simulate a single-page app route change with content swap and late-loading elements.
🔤 Font Swap Flash
Simulates FOUT (Flash of Unstyled Text) causing layout shift when fonts load.
📝 Interactive Form
Form with validation that blocks main thread. Tests INP during user input.
Measurement Code
Copy these snippets to measure Core Web Vitals on your own site.
// Measure LCP with PerformanceObserver
new PerformanceObserver((list) => {
const entries = list.getEntries();
const last = entries[entries.length - 1];
console.log('LCP:', last.startTime, 'ms');
console.log('Element:', last.element);
console.log('Size:', last.size);
}).observe({ type: 'largest-contentful-paint', buffered: true });
// Thresholds (Google):
// Good: < 2500ms
// Needs Improvement: 2500-4000ms
// Poor: > 4000ms
// Measure INP with PerformanceObserver
// INP = worst interaction latency (p98 for high-traffic)
let worstINP = 0;
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
// duration includes input delay + processing + presentation
if (entry.duration > worstINP) {
worstINP = entry.duration;
console.log('INP candidate:', entry.duration, 'ms');
console.log('Target:', entry.target);
console.log('Name:', entry.name); // e.g., "click", "keydown"
}
}
}).observe({ type: 'event', buffered: true, durationThreshold: 16 });
// Thresholds (Google):
// Good: < 200ms
// Needs Improvement: 200-500ms
// Poor: > 500ms
// Measure CLS with PerformanceObserver
let clsScore = 0;
let sessionEntries = [];
let sessionValue = 0;
let maxSessionValue = 0;
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
// Only count shifts without recent user input
if (!entry.hadRecentInput) {
// Session window: gap < 1s, total < 5s
const last = sessionEntries[sessionEntries.length - 1];
if (last && entry.startTime - last.startTime < 1000
&& entry.startTime - sessionEntries[0].startTime < 5000) {
sessionValue += entry.value;
} else {
sessionEntries = [];
sessionValue = entry.value;
}
sessionEntries.push(entry);
maxSessionValue = Math.max(maxSessionValue, sessionValue);
console.log('CLS:', maxSessionValue.toFixed(4));
}
}
}).observe({ type: 'layout-shift', buffered: true });
// Thresholds (Google):
// Good: < 0.1
// Needs Improvement: 0.1-0.25
// Poor: > 0.25
// All Core Web Vitals in one snippet
// Production-ready: use the web-vitals library instead
// npm install web-vitals
import { onLCP, onINP, onCLS } from 'web-vitals';
onLCP((metric) => {
console.log('LCP:', metric.value, metric.rating);
// metric.rating = 'good' | 'needs-improvement' | 'poor'
sendToAnalytics({ name: 'LCP', ...metric });
});
onINP((metric) => {
console.log('INP:', metric.value, metric.rating);
sendToAnalytics({ name: 'INP', ...metric });
});
onCLS((metric) => {
console.log('CLS:', metric.value, metric.rating);
sendToAnalytics({ name: 'CLS', ...metric });
});
function sendToAnalytics(data) {
// navigator.sendBeacon for reliability
navigator.sendBeacon('/analytics', JSON.stringify(data));
}
Browser Support
Core Web Vitals rely on specific Performance APIs. Support varies by browser.