Skip to content
Codeloom
Web

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.

·7 min read · By Codeloom
Intermediate 14 min read

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.