Frontend Performance: A Practical Guide to Core Web Vitals

Philip Rehberger Mar 29, 2026 6 min read

Core Web Vitals aren't just SEO metrics — they measure real user experiences that affect conversion and retention. Learn what drives each metric and the concrete techniques to improve them.

Frontend Performance: A Practical Guide to Core Web Vitals

Google's Core Web Vitals have been a ranking signal since 2021, but their real value isn't SEO — it's that they measure experiences that actually matter to users. A poor LCP means users wait too long for content. A poor CLS means they click the wrong thing because the page shifted. A poor INP means the interface feels sluggish.

Improving these metrics requires understanding what drives them, not just adding lazy loading and calling it done.

The Three Core Metrics

Largest Contentful Paint (LCP)

LCP measures when the largest image or text block in the viewport becomes visible. It's the closest proxy for "when does the user see what they came for?"

Good: < 2.5 seconds Needs improvement: 2.5-4 seconds Poor: > 4 seconds

What constitutes the LCP element? Usually:

  • Hero images
  • Above-the-fold <h1> text blocks
  • Video poster images
  • Background images in large elements
// Measure LCP in your real user monitoring
new PerformanceObserver((entryList) => {
  const entries = entryList.getEntries();
  const lastEntry = entries[entries.length - 1];

  console.log('LCP element:', lastEntry.element);
  console.log('LCP time:', lastEntry.startTime);

  // Send to your analytics
  analytics.track('web_vital', {
    metric: 'LCP',
    value: lastEntry.startTime,
    element: lastEntry.element?.tagName,
    url: window.location.pathname
  });
}).observe({ type: 'largest-contentful-paint', buffered: true });

Interaction to Next Paint (INP)

INP replaced FID in March 2024. It measures the latency of all interactions throughout a page visit, not just the first one. It captures click, tap, and keyboard event delays.

Good: < 200ms Needs improvement: 200-500ms Poor: > 500ms

// Measure INP
let maxINP = 0;
new PerformanceObserver((entryList) => {
  for (const entry of entryList.getEntries()) {
    if (entry.interactionId) {
      maxINP = Math.max(maxINP, entry.duration);
      if (entry.duration > 200) {
        console.warn('Slow interaction:', {
          type: entry.name,
          duration: entry.duration,
          target: entry.target?.tagName
        });
      }
    }
  }
}).observe({ type: 'event', durationThreshold: 16, buffered: true });

Cumulative Layout Shift (CLS)

CLS measures unexpected layout shifts — when elements move around as the page loads. Nothing is more frustrating than clicking a button that jumps away at the last moment.

Good: < 0.1 Needs improvement: 0.1-0.25 Poor: > 0.25

// Track layout shift sources
let cumulativeCLS = 0;
new PerformanceObserver((entryList) => {
  for (const entry of entryList.getEntries()) {
    if (!entry.hadRecentInput) {  // Ignore user-triggered shifts
      cumulativeCLS += entry.value;
      if (entry.value > 0.01) {
        console.log('Layout shift from:', entry.sources?.map(s => ({
          element: s.currentRect,
          node: s.node?.nodeName
        })));
      }
    }
  }
}).observe({ type: 'layout-shift', buffered: true });

Improving LCP

1. Prioritize the LCP Resource

If your LCP element is an image, the browser needs to discover and load it as early as possible. By default, images are discovered only after the browser parses the HTML. Use fetchpriority to tell the browser this image is critical:

<!-- Without optimization: browser discovers late, loads late -->
<img src="/hero.jpg" alt="Hero image">

<!-- With optimization: browser prioritizes this from the start -->
<img
  src="/hero.jpg"
  alt="Hero image"
  fetchpriority="high"
  loading="eager"
  decoding="async"
  width="1200"
  height="600"
>

For images loaded via CSS background-image (which the browser discovers even later), consider switching to an <img> tag.

2. Preload Critical Resources

<head>
  <!-- Preload the LCP image -->
  <link
    rel="preload"
    as="image"
    href="/hero.jpg"
    imagesrcset="/hero-480.jpg 480w, /hero-800.jpg 800w, /hero-1200.jpg 1200w"
    imagesizes="(max-width: 640px) 480px, (max-width: 1024px) 800px, 1200px"
  >

  <!-- Preload critical fonts -->
  <link
    rel="preload"
    as="font"
    href="/fonts/inter-regular.woff2"
    type="font/woff2"
    crossorigin
  >

  <!-- Preconnect to critical third parties -->
  <link rel="preconnect" href="https://fonts.googleapis.com">
  <link rel="dns-prefetch" href="https://analytics.example.com">
</head>

3. Serve Optimal Image Formats

<!-- Use modern formats with fallback -->
<picture>
  <source
    type="image/avif"
    srcset="/hero-480.avif 480w, /hero-800.avif 800w, /hero-1200.avif 1200w"
    sizes="(max-width: 640px) 480px, (max-width: 1024px) 800px, 1200px"
  >
  <source
    type="image/webp"
    srcset="/hero-480.webp 480w, /hero-800.webp 800w, /hero-1200.webp 1200w"
    sizes="(max-width: 640px) 480px, (max-width: 1024px) 800px, 1200px"
  >
  <img
    src="/hero-1200.jpg"
    alt="Hero image"
    width="1200"
    height="600"
    fetchpriority="high"
  >
</picture>

AVIF is typically 40-60% smaller than JPEG at the same quality. WebP is 25-35% smaller. Browser support for both is now excellent.

4. Eliminate Render-Blocking Resources

<!-- Blocking: browser must parse this before rendering -->
<link rel="stylesheet" href="/styles.css">
<script src="/app.js"></script>

<!-- Non-blocking -->
<link rel="stylesheet" href="/styles.css" media="print" onload="this.media='all'">
<script src="/app.js" defer></script>
<script src="/analytics.js" async></script>

For critical CSS (above-the-fold styles), inline it in <head>. Load the rest asynchronously:

<head>
  <style>
    /* Critical CSS — inline only above-the-fold styles */
    body { margin: 0; font-family: Inter, sans-serif; }
    .hero { width: 100%; height: 60vh; background: #f5f5f5; }
    .nav { display: flex; height: 64px; align-items: center; }
  </style>

  <!-- Non-critical styles load asynchronously -->
  <link rel="preload" as="style" href="/styles.css"
    onload="this.rel='stylesheet'">
  <noscript><link rel="stylesheet" href="/styles.css"></noscript>
</head>

Improving INP

INP is about main thread responsiveness. Long tasks block the main thread, making interactions feel sluggish.

1. Break Up Long Tasks

// Before: blocking task
function processData(items) {
  return items.map(item => heavyComputation(item));
  // If this takes 500ms, no interactions are handled for 500ms
}

// After: chunked processing with yielding
async function processDataChunked(items) {
  const results = [];
  const CHUNK_SIZE = 50;

  for (let i = 0; i < items.length; i += CHUNK_SIZE) {
    const chunk = items.slice(i, i + CHUNK_SIZE);
    results.push(...chunk.map(item => heavyComputation(item)));

    // Yield to browser between chunks
    await new Promise(resolve => setTimeout(resolve, 0));
    // Or use scheduler.yield() in supporting browsers:
    // if ('scheduler' in window) await scheduler.yield();
  }

  return results;
}

2. Move Work Off the Main Thread

// worker.js
self.onmessage = function(event) {
  const { items } = event.data;
  const results = items.map(item => heavyComputation(item));
  self.postMessage(results);
};

// main thread
const worker = new Worker('/worker.js');

function processWithWorker(items) {
  return new Promise((resolve) => {
    worker.onmessage = (event) => resolve(event.data);
    worker.postMessage({ items });
  });
  // Main thread stays responsive throughout
}

3. Reduce JavaScript Bundle Size

Every byte of JavaScript must be parsed and compiled, which happens on the main thread:

// webpack.config.js: analyze what's in your bundle
const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer');

module.exports = {
  plugins: [
    new BundleAnalyzerPlugin()
  ]
};
# Run analysis
npx webpack --profile --json > stats.json
npx webpack-bundle-analyzer stats.json

# Key things to look for:
# - Duplicate dependencies (two versions of lodash)
# - Large libraries used for tiny features (moment.js for date formatting)
# - Libraries that could be replaced with smaller alternatives

Common wins:

  • Replace moment.js (72KB) with date-fns or native Intl API
  • Replace lodash import with specific imports: import debounce from 'lodash/debounce'
  • Replace large icon libraries with individual SVG files

Improving CLS

1. Always Specify Image Dimensions

The browser reserves space for images only if it knows their dimensions before loading:

<!-- Causes CLS: browser doesn't know height until loaded -->
<img src="/product.jpg" alt="Product">

<!-- No CLS: browser reserves exact space immediately -->
<img src="/product.jpg" alt="Product" width="400" height="300">

<!-- CSS responsive with preserved aspect ratio -->
<style>
  img { width: 100%; height: auto; }
</style>

2. Reserve Space for Dynamic Content

/* Ad slot: reserve space before ad loads */
.ad-container {
  min-height: 250px;  /* Known ad size */
  width: 300px;
  background: #f0f0f0;  /* Placeholder visual */
}

/* Content loaded via JS: use skeleton screens */
.skeleton-line {
  height: 1em;
  background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
  background-size: 200% 100%;
  animation: shimmer 1.5s infinite;
  margin-bottom: 0.5em;
}

3. Avoid Inserting Content Above Existing Content

// Causes CLS: banner inserted above existing content
document.body.insertBefore(cookieBanner, document.body.firstChild);

// No CLS: use position:fixed or sticky so it doesn't affect layout
// Or pre-reserve the space with min-height
document.querySelector('.banner-placeholder').innerHTML = cookieBannerHTML;

Measuring Real User Metrics

Lab tools (Lighthouse, WebPageTest) are valuable but don't capture real user experience across all devices and network conditions. Use the web-vitals library to collect field data:

import { onLCP, onINP, onCLS, onFCP, onTTFB } from 'web-vitals';

function sendToAnalytics({ name, value, rating, id }) {
  fetch('/api/vitals', {
    method: 'POST',
    body: JSON.stringify({
      metric: name,
      value: Math.round(name === 'CLS' ? value * 1000 : value),
      rating,  // 'good', 'needs-improvement', or 'poor'
      id,
      url: window.location.href,
      userAgent: navigator.userAgent
    }),
    keepalive: true  // Ensures the request completes even if page unloads
  });
}

onLCP(sendToAnalytics);
onINP(sendToAnalytics);
onCLS(sendToAnalytics);
onFCP(sendToAnalytics);
onTTFB(sendToAnalytics);

The Optimization Workflow

1. Measure: Collect real user data (web-vitals library + backend)
2. Prioritize: Which metric is worst? Which pages affect most users?
3. Diagnose: Use DevTools Performance panel and Lighthouse
4. Fix: Apply targeted optimization
5. Verify: Deploy to subset of traffic, compare before/after
6. Monitor: Alert if metrics regress

Track metrics by page type (home, product, checkout), by device type (mobile vs desktop), and by connection speed. The same page can have a good LCP on fiber and a poor LCP on 4G — and the 4G experience may be what most of your users have.

Small improvements compound. Moving LCP from 3.5s to 2.4s often shows measurable improvements in conversion and bounce rate. The work pays off.

Building something that needs to scale? We help teams architect systems that grow with their business. scopeforged.com

Share this article

Related Articles

Need help with your project?

Let's discuss how we can help you build reliable software.