Measuring and Fixing Core Web Vitals
Hands-on techniques for measuring LCP, INP, and CLS with real code: the web-vitals library, Performance Observer, Chrome UX Report, and step-by-step fixes for each metric.
What you'll learn
- ✓How to instrument your site with the web-vitals library
- ✓How to use PerformanceObserver for custom metrics
- ✓Step-by-step fixes for LCP bottlenecks
- ✓How to diagnose and eliminate layout shifts (CLS)
- ✓How to reduce INP with task splitting and scheduling
Prerequisites
- •Basic JavaScript
- •Familiarity with browser DevTools
Measuring First, Fixing Second
Performance optimization without measurement is guessing. This article focuses on the measurement side first — getting real numbers from real users — then walks through specific fixes for each Core Web Vital.
Setting Up the web-vitals Library
Google’s web-vitals library is the canonical way to measure Core Web Vitals in the field:
npm install web-vitals
// src/lib/vitals.ts
import { onLCP, onINP, onCLS, onFCP, onTTFB } from 'web-vitals';
interface VitalMetric {
name: string;
value: number;
rating: 'good' | 'needs-improvement' | 'poor';
delta: number;
id: string;
navigationType: string;
}
function sendToAnalytics(metric: VitalMetric) {
// Send to your analytics endpoint
const body = JSON.stringify({
name: metric.name,
value: Math.round(metric.value),
rating: metric.rating,
delta: Math.round(metric.delta),
id: metric.id,
page: window.location.pathname,
navigationType: metric.navigationType,
timestamp: Date.now(),
});
// Use sendBeacon so it survives page unload
if (navigator.sendBeacon) {
navigator.sendBeacon('/api/vitals', body);
} else {
fetch('/api/vitals', {
method: 'POST',
body,
keepalive: true,
headers: { 'Content-Type': 'application/json' },
});
}
}
// Initialize all measurements
export function initVitals() {
onLCP(sendToAnalytics);
onINP(sendToAnalytics);
onCLS(sendToAnalytics);
onFCP(sendToAnalytics);
onTTFB(sendToAnalytics);
}
<script type="module">
import { initVitals } from '/src/lib/vitals.ts';
initVitals();
</script>
The Analytics Endpoint
// src/pages/api/vitals.ts (Astro example)
import type { APIRoute } from 'astro';
export const POST: APIRoute = async ({ request }) => {
const metric = await request.json();
// Log to console in development
console.log(`[Vital] ${metric.name}: ${metric.value} (${metric.rating})`);
// In production, write to a database or analytics service
// await db.insert('web_vitals', metric);
return new Response(null, { status: 204 });
};
Using PerformanceObserver Directly
For custom metrics beyond Core Web Vitals:
// Measure how long specific elements take to render
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
console.log(`${entry.name}: ${Math.round(entry.startTime)}ms`);
}
});
// Observe Largest Contentful Paint candidates
observer.observe({ type: 'largest-contentful-paint', buffered: true });
// Observe layout shifts
const clsObserver = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (!entry.hadRecentInput) {
console.log('Layout shift:', entry.value, entry.sources);
// entry.sources tells you WHICH element shifted
entry.sources?.forEach((source) => {
console.log(' Shifted element:', source.node);
console.log(' Previous rect:', source.previousRect);
console.log(' Current rect:', source.currentRect);
});
}
}
});
clsObserver.observe({ type: 'layout-shift', buffered: true });
// Observe long tasks (> 50ms)
const longTaskObserver = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
console.log(`Long task: ${Math.round(entry.duration)}ms`);
}
});
longTaskObserver.observe({ type: 'longtask', buffered: true });
Fixing LCP
LCP measures when the largest visible element finishes rendering. The target is under 2.5 seconds.
Step 1: Identify the LCP Element
new PerformanceObserver((list) => {
const entries = list.getEntries();
const lastEntry = entries[entries.length - 1];
console.log('LCP element:', lastEntry.element);
console.log('LCP time:', lastEntry.startTime);
}).observe({ type: 'largest-contentful-paint', buffered: true });
Step 2: Common LCP Fixes
Slow images: The LCP element is usually an image. Optimize it:
<!-- Before: unoptimized -->
<img src="/hero.png" alt="Hero">
<!-- After: optimized -->
<img
src="/hero.webp"
alt="Hero"
width="1200"
height="630"
loading="eager"
fetchpriority="high"
decoding="async"
srcset="/hero-400.webp 400w, /hero-800.webp 800w, /hero-1200.webp 1200w"
sizes="100vw"
/>
<!-- Preload it in the head -->
<link rel="preload" as="image" href="/hero.webp" fetchpriority="high" />
Render-blocking resources: CSS and synchronous scripts block rendering.
<!-- Before: blocks rendering -->
<link rel="stylesheet" href="/all-styles.css" />
<!-- After: inline critical CSS, defer the rest -->
<style>
/* Only above-the-fold styles */
body { margin: 0; font-family: system-ui; }
.hero { min-height: 100vh; }
</style>
<link rel="stylesheet" href="/non-critical.css" media="print" onload="this.media='all'" />
Slow server response (TTFB): If the server takes too long to respond, LCP cannot be fast.
// Add server-timing headers to diagnose
response.headers.set(
'Server-Timing',
`db;dur=${dbTime}, render;dur=${renderTime}, total;dur=${totalTime}`,
);
Step 3: Preconnect to Third-Party Origins
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://cdn.example.com" crossorigin />
Fixing CLS
CLS measures unexpected layout shifts. Target is under 0.1.
Step 1: Find the Shifting Elements
Use the PerformanceObserver from above. The sources property tells you exactly which DOM elements shifted.
Step 2: Common CLS Fixes
Images without dimensions:
<!-- Before: causes CLS when image loads -->
<img src="/photo.jpg" alt="Photo" />
<!-- After: browser reserves space -->
<img src="/photo.jpg" alt="Photo" width="800" height="600" />
<!-- Or use aspect-ratio in CSS -->
<style>
.image-container {
aspect-ratio: 16 / 9;
overflow: hidden;
}
</style>
Dynamically injected content:
/* Reserve space for ads, embeds, or async content */
.ad-slot {
min-height: 250px;
}
.tweet-embed {
min-height: 300px;
}
Web fonts causing reflow:
/* Use font-display to control behavior */
@font-face {
font-family: 'CustomFont';
src: url('/fonts/custom.woff2') format('woff2');
font-display: optional; /* Prevents layout shift entirely */
/* swap shows fallback then swaps (slight shift) */
/* optional shows fallback and only swaps if font loads very fast */
}
/* Size-adjust to match fallback font metrics */
@font-face {
font-family: 'CustomFont-Fallback';
src: local('Arial');
ascent-override: 90%;
descent-override: 20%;
line-gap-override: 0%;
size-adjust: 105%;
}
Top bars and banners injected after load:
/* Bad: cookie banner pushes content down */
.cookie-banner {
position: relative; /* Takes up layout space when injected */
}
/* Good: overlay that doesn't shift content */
.cookie-banner {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 1000;
}
Fixing INP
INP measures the delay between a user interaction and the next visual update. Target is under 200ms.
Step 1: Identify Slow Interactions
import { onINP } from 'web-vitals';
onINP((metric) => {
console.log('INP:', metric.value, 'ms');
// metric.entries contains the PerformanceEventTiming entries
metric.entries.forEach((entry) => {
console.log(' Event:', entry.name);
console.log(' Processing time:', entry.processingEnd - entry.processingStart, 'ms');
console.log(' Input delay:', entry.processingStart - entry.startTime, 'ms');
console.log(' Presentation delay:', entry.duration - (entry.processingEnd - entry.startTime), 'ms');
});
});
Step 2: Break Up Long Tasks
// Before: one long task blocks the main thread
function processLargeList(items: Item[]) {
for (const item of items) {
expensiveOperation(item); // 500ms total
}
updateDOM();
}
// After: yield to the main thread between chunks
async function processLargeList(items: Item[]) {
const CHUNK_SIZE = 50;
for (let i = 0; i < items.length; i += CHUNK_SIZE) {
const chunk = items.slice(i, i + CHUNK_SIZE);
for (const item of chunk) {
expensiveOperation(item);
}
// Yield to the browser between chunks
await new Promise((resolve) => setTimeout(resolve, 0));
}
updateDOM();
}
Step 3: Use scheduler.yield()
The modern approach for yielding:
async function handleClick() {
// Do critical work first
updateButtonState();
// Yield so the browser can paint
if ('scheduler' in globalThis && 'yield' in scheduler) {
await scheduler.yield();
} else {
await new Promise((r) => setTimeout(r, 0));
}
// Continue with non-critical work
await fetchData();
renderResults();
}
Step 4: Debounce Input Handlers
function debounce<T extends (...args: any[]) => void>(fn: T, delay: number): T {
let timer: ReturnType<typeof setTimeout>;
return ((...args: any[]) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), delay);
}) as T;
}
// Before: fires on every keystroke
searchInput.addEventListener('input', (e) => {
filterResults(e.target.value); // Expensive DOM update
});
// After: fires 150ms after the user stops typing
searchInput.addEventListener('input', debounce((e) => {
filterResults(e.target.value);
}, 150));
Building a Vitals Dashboard
// Aggregate vitals data for a simple dashboard
interface VitalsSummary {
name: string;
p75: number;
good: number;
needsImprovement: number;
poor: number;
total: number;
}
function summarizeVitals(metrics: VitalMetric[]): VitalsSummary[] {
const grouped = new Map<string, VitalMetric[]>();
for (const m of metrics) {
if (!grouped.has(m.name)) grouped.set(m.name, []);
grouped.get(m.name)!.push(m);
}
return Array.from(grouped.entries()).map(([name, values]) => {
const sorted = values.map((v) => v.value).sort((a, b) => a - b);
const p75Index = Math.floor(sorted.length * 0.75);
return {
name,
p75: sorted[p75Index] ?? 0,
good: values.filter((v) => v.rating === 'good').length,
needsImprovement: values.filter((v) => v.rating === 'needs-improvement').length,
poor: values.filter((v) => v.rating === 'poor').length,
total: values.length,
};
});
}
Summary
Measure before you fix. The web-vitals library gives you field data from real users. PerformanceObserver gives you granular detail for debugging. LCP fixes center on image optimization, preloading, and reducing server response time. CLS fixes are about reserving space and avoiding dynamic injection above content. INP fixes require breaking up long tasks and yielding to the browser. Ship the measurement code first, collect a week of data, then fix the worst offenders.
Related articles
- Web Web Performance: Core Web Vitals Guide
A practical guide to Core Web Vitals: LCP, INP, and CLS. How they are measured, what hurts them, and concrete fixes that move the numbers.
- Web Core Web Vitals Optimization Techniques
Hands-on techniques to improve LCP, INP, and CLS scores: image optimization, script deferral, layout stability patterns, and interaction responsiveness strategies.
- Web Advanced Browser DevTools Tips and Tricks
Go beyond console.log: Network throttling, Performance flame charts, Memory snapshots, CSS debugging, JavaScript profiling, and hidden DevTools features that save hours.
- Web The Web Animations API: CSS vs JavaScript Animations
A practical guide to the Web Animations API (WAAPI): creating animations in JavaScript, controlling playback, composing effects, performance tips, and when to use CSS vs JS.