Web Vitals and Performance Metrics in JavaScript
Web Vitals are Google’s metrics for measuring user experience. This article covers understanding, measuring, and optimizing these critical metrics.
Introduction
Web Vitals measure:
- Loading performance (LCP)
- Interactivity (FID, INP)
- Visual stability (CLS)
Understanding Web Vitals helps you:
- Improve user experience
- Rank better in search results
- Reduce bounce rates
- Increase conversions
Core Web Vitals
Largest Contentful Paint (LCP)
// LCP: Time when largest content element is visible
// Target: < 2.5 seconds
// Measuring LCP
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
console.log('LCP:', entry.renderTime || entry.loadTime);
}
});
observer.observe({ entryTypes: ['largest-contentful-paint'] });
// Practical example: Monitoring LCP
class LCPMonitor {
constructor() {
this.lcpValue = null;
}
start() {
const observer = new PerformanceObserver((list) => {
const entries = list.getEntries();
const lastEntry = entries[entries.length - 1];
this.lcpValue = lastEntry.renderTime || lastEntry.loadTime;
console.log('LCP updated:', this.lcpValue);
// Send to analytics
this.reportMetric('LCP', this.lcpValue);
});
observer.observe({ entryTypes: ['largest-contentful-paint'] });
}
reportMetric(name, value) {
// Send to analytics service
fetch('/api/metrics', {
method: 'POST',
body: JSON.stringify({ name, value })
});
}
getValue() {
return this.lcpValue;
}
}
// Usage
const lcpMonitor = new LCPMonitor();
lcpMonitor.start();
First Input Delay (FID)
// FID: Time from user input to browser response
// Target: < 100 milliseconds
// Measuring FID
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
console.log('FID:', entry.processingDuration);
}
});
observer.observe({ entryTypes: ['first-input'] });
// Practical example: Monitoring FID
class FIDMonitor {
constructor() {
this.fidValue = null;
}
start() {
const observer = new PerformanceObserver((list) => {
const entries = list.getEntries();
const firstEntry = entries[0];
this.fidValue = firstEntry.processingDuration;
console.log('FID:', this.fidValue);
// Send to analytics
this.reportMetric('FID', this.fidValue);
});
observer.observe({ entryTypes: ['first-input'] });
}
reportMetric(name, value) {
fetch('/api/metrics', {
method: 'POST',
body: JSON.stringify({ name, value })
});
}
getValue() {
return this.fidValue;
}
}
// Usage
const fidMonitor = new FIDMonitor();
fidMonitor.start();
Cumulative Layout Shift (CLS)
// CLS: Measure of visual stability
// Target: < 0.1
// Measuring CLS
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (!entry.hadRecentInput) {
console.log('CLS:', entry.value);
}
}
});
observer.observe({ entryTypes: ['layout-shift'] });
// Practical example: Monitoring CLS
class CLSMonitor {
constructor() {
this.clsValue = 0;
}
start() {
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (!entry.hadRecentInput) {
this.clsValue += entry.value;
console.log('CLS updated:', this.clsValue);
// Send to analytics
this.reportMetric('CLS', this.clsValue);
}
}
});
observer.observe({ entryTypes: ['layout-shift'] });
}
reportMetric(name, value) {
fetch('/api/metrics', {
method: 'POST',
body: JSON.stringify({ name, value })
});
}
getValue() {
return this.clsValue;
}
}
// Usage
const clsMonitor = new CLSMonitor();
clsMonitor.start();
Additional Web Vitals
Interaction to Next Paint (INP)
// INP: Measures responsiveness to all interactions
// Target: < 200 milliseconds
// Measuring INP
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
console.log('INP:', entry.duration);
}
});
observer.observe({ entryTypes: ['event'] });
Time to First Byte (TTFB)
// TTFB: Time from request to first byte received
// Target: < 600 milliseconds
// Measuring TTFB
const perfData = performance.getEntriesByType('navigation')[0];
const ttfb = perfData.responseStart - perfData.fetchStart;
console.log('TTFB:', ttfb);
Comprehensive Metrics Monitoring
Web Vitals Library
// Using web-vitals library
import { getCLS, getFID, getFCP, getLCP, getTTFB } from 'web-vitals';
function sendMetric(metric) {
// Send to analytics
fetch('/api/metrics', {
method: 'POST',
body: JSON.stringify(metric)
});
}
getCLS(sendMetric);
getFID(sendMetric);
getFCP(sendMetric);
getLCP(sendMetric);
getTTFB(sendMetric);
Custom Metrics Dashboard
// Create comprehensive metrics dashboard
class MetricsDashboard {
constructor() {
this.metrics = {
LCP: null,
FID: null,
CLS: 0,
TTFB: null,
FCP: null,
DCL: null,
Load: null
};
}
start() {
this.measureNavigationTiming();
this.measureLCP();
this.measureFID();
this.measureCLS();
}
measureNavigationTiming() {
window.addEventListener('load', () => {
const perfData = performance.getEntriesByType('navigation')[0];
this.metrics.TTFB = perfData.responseStart - perfData.fetchStart;
this.metrics.FCP = performance.getEntriesByName('first-contentful-paint')[0]?.startTime;
this.metrics.DCL = perfData.domContentLoadedEventEnd - perfData.fetchStart;
this.metrics.Load = perfData.loadEventEnd - perfData.fetchStart;
this.reportMetrics();
});
}
measureLCP() {
const observer = new PerformanceObserver((list) => {
const entries = list.getEntries();
const lastEntry = entries[entries.length - 1];
this.metrics.LCP = lastEntry.renderTime || lastEntry.loadTime;
this.reportMetrics();
});
observer.observe({ entryTypes: ['largest-contentful-paint'] });
}
measureFID() {
const observer = new PerformanceObserver((list) => {
const firstEntry = list.getEntries()[0];
this.metrics.FID = firstEntry.processingDuration;
this.reportMetrics();
});
observer.observe({ entryTypes: ['first-input'] });
}
measureCLS() {
let clsValue = 0;
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (!entry.hadRecentInput) {
clsValue += entry.value;
this.metrics.CLS = clsValue;
this.reportMetrics();
}
}
});
observer.observe({ entryTypes: ['layout-shift'] });
}
reportMetrics() {
console.log('Current Metrics:', this.metrics);
// Send to analytics
fetch('/api/metrics', {
method: 'POST',
body: JSON.stringify(this.metrics)
});
}
getMetrics() {
return this.metrics;
}
}
// Usage
const dashboard = new MetricsDashboard();
dashboard.start();
Optimizing Web Vitals
Improving LCP
// 1. Optimize server response time
// - Use CDN
// - Implement caching
// - Optimize database queries
// 2. Reduce CSS blocking time
// - Inline critical CSS
// - Defer non-critical CSS
// 3. Reduce JavaScript blocking time
// - Code split
// - Lazy load
// - Defer non-critical scripts
// 4. Optimize images
// - Use modern formats (WebP)
// - Responsive images
// - Lazy load images
// Example: Lazy load images
const images = document.querySelectorAll('img[data-src]');
const imageObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target;
img.src = img.dataset.src;
imageObserver.unobserve(img);
}
});
});
images.forEach(img => imageObserver.observe(img));
Improving FID
// 1. Break up long tasks
// - Use requestIdleCallback
// - Use setTimeout to yield to browser
// 2. Use web workers
// - Offload heavy computation
// - Keep main thread responsive
// 3. Reduce JavaScript
// - Code split
// - Remove unused code
// - Use smaller libraries
// Example: Break up long task
function heavyComputation() {
const tasks = [];
// Split work into chunks
for (let i = 0; i < 1000; i++) {
tasks.push(() => {
// Do work
Math.sqrt(i);
});
}
// Process tasks with delays
let taskIndex = 0;
function processTasks() {
const startTime = performance.now();
while (taskIndex < tasks.length && performance.now() - startTime < 5) {
tasks[taskIndex]();
taskIndex++;
}
if (taskIndex < tasks.length) {
setTimeout(processTasks, 0);
}
}
processTasks();
}
Improving CLS
// 1. Avoid layout shifts
// - Set size for images and videos
// - Avoid inserting content above existing content
// - Use transform instead of layout properties
// 2. Use font-display: swap
// - Prevent font swap layout shift
// 3. Avoid animations that cause layout shifts
// - Use transform and opacity
// - Avoid animating layout properties
// Example: Prevent image layout shift
// โ
Good: Set dimensions
<img src="image.jpg" width="400" height="300" />
// โ Bad: No dimensions
<img src="image.jpg" />
// Example: Use transform instead of position
// โ
Good: Use transform
element.style.transform = 'translateX(10px)';
// โ Bad: Use left property
element.style.left = '10px';
Performance Budgets
Setting Budgets
// performance-budget.json
{
"bundles": [
{
"name": "main",
"maxSize": "250kb"
},
{
"name": "vendor",
"maxSize": "200kb"
}
],
"metrics": [
{
"name": "LCP",
"maxValue": 2500
},
{
"name": "FID",
"maxValue": 100
},
{
"name": "CLS",
"maxValue": 0.1
}
]
}
Enforcing Budgets
// scripts/check-performance-budget.js
const fs = require('fs');
const path = require('path');
function checkPerformanceBudget() {
const budget = JSON.parse(
fs.readFileSync('performance-budget.json', 'utf8')
);
const metrics = {
LCP: 2300,
FID: 95,
CLS: 0.08
};
let passed = true;
budget.metrics.forEach(metric => {
if (metrics[metric.name] > metric.maxValue) {
console.error(`โ ${metric.name} exceeds budget: ${metrics[metric.name]} > ${metric.maxValue}`);
passed = false;
} else {
console.log(`โ
${metric.name} within budget: ${metrics[metric.name]} < ${metric.maxValue}`);
}
});
if (!passed) {
process.exit(1);
}
}
checkPerformanceBudget();
Monitoring in Production
Analytics Integration
// Send metrics to analytics service
class AnalyticsReporter {
constructor(endpoint) {
this.endpoint = endpoint;
}
reportMetric(name, value) {
// Use sendBeacon for reliability
const data = JSON.stringify({
name,
value,
url: window.location.href,
timestamp: Date.now()
});
navigator.sendBeacon(this.endpoint, data);
}
reportWebVitals() {
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
getCLS(metric => this.reportMetric('CLS', metric.value));
getFID(metric => this.reportMetric('FID', metric.value));
getFCP(metric => this.reportMetric('FCP', metric.value));
getLCP(metric => this.reportMetric('LCP', metric.value));
getTTFB(metric => this.reportMetric('TTFB', metric.value));
});
}
}
// Usage
const reporter = new AnalyticsReporter('/api/metrics');
reporter.reportWebVitals();
Best Practices
-
Monitor all Web Vitals:
// โ Good getCLS(sendMetric); getFID(sendMetric); getLCP(sendMetric); -
Set performance budgets:
// โ Good // LCP < 2.5s, FID < 100ms, CLS < 0.1 -
Optimize for real users:
// โ Good // Monitor in production // Use real user metrics -
Test on slow networks:
// โ Good // Test on 3G, 4G networks // Use DevTools throttling
Common Mistakes
-
Only testing on fast networks:
// โ Bad - only test on fast connection // โ Good - test on slow networks too -
Ignoring real user metrics:
// โ Bad - only use lab metrics // โ Good - monitor real users -
Not setting budgets:
// โ Bad - no performance targets // โ Good - set and enforce budgets
Summary
Web Vitals are essential for user experience. Key takeaways:
- Measure LCP (< 2.5s), FID (< 100ms), CLS (< 0.1)
- Monitor in production with real users
- Set and enforce performance budgets
- Optimize for slow networks
- Use web-vitals library for easy measurement
- Report metrics to analytics
- Continuously improve metrics
Related Resources
- Web Vitals - Google
- Core Web Vitals - Google
- web-vitals Library - GitHub
- Performance API - MDN
- Performance Observer - MDN
Next Steps
- Learn about Performance Profiling: DevTools and Metrics
- Explore Rendering Performance: Reflow and Repaint
- Study Code Splitting and Lazy Loading
- Implement Web Vitals monitoring in your applications
- Set and enforce performance budgets
- Monitor metrics in production
Comments