Frontend Performance: Core Web Vitals Optimization

Core Web Vitals are Google's attempt to quantify user experience through three specific metrics that measure loading performance, interactivity, and visual stability. Unlike vanity metrics, these...

Key Insights

  • Core Web Vitals directly impact search rankings and conversion rates—LCP under 2.5s, FID under 100ms, and CLS under 0.1 are the targets you need to hit
  • Most performance wins come from optimizing the critical rendering path: preload hero images, lazy load everything else, and always specify image dimensions to prevent layout shifts
  • Real user monitoring beats synthetic tests every time—instrument your production app with the web-vitals library and track actual user experiences, not lab conditions

Introduction to Core Web Vitals

Core Web Vitals are Google’s attempt to quantify user experience through three specific metrics that measure loading performance, interactivity, and visual stability. Unlike vanity metrics, these actually correlate with user behavior—slow sites lose users and money.

The three metrics are:

  • Largest Contentful Paint (LCP): Measures when the largest content element becomes visible. Target: under 2.5 seconds.
  • First Input Delay (FID) (being replaced by Interaction to Next Paint - INP): Measures responsiveness to user interactions. Target: under 100ms for FID, under 200ms for INP.
  • Cumulative Layout Shift (CLS): Measures visual stability. Target: under 0.1.

Google uses these for search ranking, but more importantly, they correlate with conversion rates. A 100ms improvement in LCP can increase conversion by 1-2%. That’s real money.

Here’s how to measure these metrics in production:

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

function sendToAnalytics({name, delta, value, id}) {
  // Send to your analytics endpoint
  const body = JSON.stringify({name, delta, value, id});
  
  // Use sendBeacon to ensure data is sent even if user navigates away
  if (navigator.sendBeacon) {
    navigator.sendBeacon('/analytics', body);
  } else {
    fetch('/analytics', {
      body,
      method: 'POST',
      keepalive: true
    });
  }
}

// Measure all Core Web Vitals
onCLS(sendToAnalytics);
onFID(sendToAnalytics);
onLCP(sendToAnalytics);
onINP(sendToAnalytics); // INP is replacing FID

This gives you real user data, not synthetic lab scores. Lab scores are useful for debugging, but field data tells you what actual users experience.

Optimizing Largest Contentful Paint (LCP)

LCP typically measures your hero image, main heading, or the first significant content block. Open Chrome DevTools, run a Lighthouse audit, and check the “Largest Contentful Paint element” section to identify what’s being measured.

The fastest LCP wins come from three techniques:

1. Preload critical resources

If your LCP element is an image, preload it. This tells the browser to fetch it immediately instead of waiting for the CSS parser to discover it.

<head>
  <link rel="preload" 
        as="image" 
        href="/hero-image.webp"
        imagesrcset="/hero-sm.webp 640w, /hero-md.webp 1024w, /hero-lg.webp 1920w"
        imagesizes="100vw">
</head>

2. Optimize and serve responsive images

Never serve a 3000px image when the viewport is 375px. Use modern formats and responsive syntax:

<img 
  src="/hero.webp"
  srcset="/hero-sm.webp 640w,
          /hero-md.webp 1024w,
          /hero-lg.webp 1920w"
  sizes="(max-width: 640px) 100vw,
         (max-width: 1024px) 100vw,
         1920px"
  alt="Hero image"
  width="1920"
  height="1080"
  fetchpriority="high"
/>

The fetchpriority="high" attribute is newer but well-supported. It’s more explicit than preload for images that are definitely LCP candidates.

3. Lazy load everything that’s not above the fold

<!-- Above the fold - load immediately -->
<img src="/hero.webp" alt="Hero" width="1920" height="1080">

<!-- Below the fold - lazy load -->
<img src="/feature-1.webp" alt="Feature" loading="lazy" width="800" height="600">
<img src="/feature-2.webp" alt="Feature" loading="lazy" width="800" height="600">

For server-side rendering, ensure your HTML includes the LCP element in the initial response. Client-side rendered apps that show spinners while fetching data will always have poor LCP.

Improving First Input Delay and Interaction to Next Paint

FID measures the delay between a user’s first interaction and when the browser can respond. INP (its replacement) measures responsiveness across all interactions. Both are killed by long-running JavaScript tasks that block the main thread.

Code splitting is non-negotiable

Don’t ship one massive bundle. Split at route boundaries and lazy load:

// Instead of this
import HomePage from './pages/Home';
import DashboardPage from './pages/Dashboard';
import SettingsPage from './pages/Settings';

// Do this
const HomePage = lazy(() => import('./pages/Home'));
const DashboardPage = lazy(() => import('./pages/Dashboard'));
const SettingsPage = lazy(() => import('./pages/Settings'));

function App() {
  return (
    <Suspense fallback={<LoadingSpinner />}>
      <Routes>
        <Route path="/" element={<HomePage />} />
        <Route path="/dashboard" element={<DashboardPage />} />
        <Route path="/settings" element={<SettingsPage />} />
      </Routes>
    </Suspense>
  );
}

Debounce and throttle expensive event handlers

Scroll and resize handlers that trigger heavy calculations destroy interactivity:

// Bad - runs on every scroll event
window.addEventListener('scroll', () => {
  updateScrollProgress();
  checkVisibleElements();
  updateParallaxEffects();
});

// Good - throttled to run at most every 100ms
import {throttle} from 'lodash-es';

const handleScroll = throttle(() => {
  updateScrollProgress();
  checkVisibleElements();
  updateParallaxEffects();
}, 100);

window.addEventListener('scroll', handleScroll, {passive: true});

The {passive: true} option tells the browser you won’t call preventDefault(), allowing it to scroll immediately without waiting for your handler.

Offload heavy work to Web Workers

Image processing, data parsing, and complex calculations should never block the main thread:

// worker.js
self.addEventListener('message', (e) => {
  const {data, operation} = e.data;
  
  if (operation === 'processImage') {
    const result = expensiveImageProcessing(data);
    self.postMessage({result});
  }
});

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

worker.postMessage({
  operation: 'processImage',
  data: imageData
});

worker.addEventListener('message', (e) => {
  const {result} = e.data;
  displayProcessedImage(result);
});

Reducing Cumulative Layout Shift

CLS measures unexpected layout shifts. Every time content moves after initial render, users get frustrated. The score is calculated from impact fraction × distance fraction for each shift.

Always specify image dimensions

This is the easiest win:

<!-- Bad - causes layout shift when image loads -->
<img src="/product.jpg" alt="Product">

<!-- Good - reserves space, no shift -->
<img src="/product.jpg" alt="Product" width="800" height="600">

<!-- Better - maintains aspect ratio responsively -->
<img src="/product.jpg" 
     alt="Product" 
     width="800" 
     height="600"
     style="max-width: 100%; height: auto;">

Modern CSS makes this even cleaner with aspect-ratio:

img {
  max-width: 100%;
  height: auto;
  aspect-ratio: 16 / 9;
}

Optimize web font loading

Fonts cause shifts when they swap from system fonts to custom fonts. Control this with font-display:

@font-face {
  font-family: 'CustomFont';
  src: url('/fonts/custom.woff2') format('woff2');
  font-display: swap; /* Show fallback immediately, swap when loaded */
  font-weight: 400;
}

/* Or use optional to prevent layout shift entirely */
@font-face {
  font-family: 'CustomFont';
  src: url('/fonts/custom.woff2') format('woff2');
  font-display: optional; /* Use custom font only if cached */
  font-weight: 400;
}

Preload critical fonts to reduce swap time:

<link rel="preload" 
      href="/fonts/custom.woff2" 
      as="font" 
      type="font/woff2" 
      crossorigin>

Reserve space for dynamic content

Use skeleton screens or min-height for content that loads asynchronously:

.user-profile-card {
  min-height: 200px; /* Prevents shift when content loads */
}

.skeleton {
  background: linear-gradient(
    90deg,
    #f0f0f0 25%,
    #e0e0e0 50%,
    #f0f0f0 75%
  );
  background-size: 200% 100%;
  animation: loading 1.5s infinite;
}

@keyframes loading {
  0% { background-position: 200% 0; }
  100% { background-position: -200% 0; }
}

Monitoring and Continuous Optimization

Performance degrades over time. You need continuous monitoring and automated checks to catch regressions before they hit production.

Set up Lighthouse CI

Run Lighthouse on every pull request:

// lighthouserc.js
module.exports = {
  ci: {
    collect: {
      url: ['http://localhost:3000/', 'http://localhost:3000/products'],
      numberOfRuns: 3,
    },
    assert: {
      preset: 'lighthouse:recommended',
      assertions: {
        'largest-contentful-paint': ['error', {maxNumericValue: 2500}],
        'cumulative-layout-shift': ['error', {maxNumericValue: 0.1}],
        'total-blocking-time': ['error', {maxNumericValue: 200}],
      },
    },
    upload: {
      target: 'temporary-public-storage',
    },
  },
};

Add to your CI pipeline:

# .github/workflows/performance.yml
name: Performance
on: [pull_request]
jobs:
  lighthouse:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
      - name: Build app
        run: npm ci && npm run build
      - name: Run Lighthouse CI
        run: |
          npm install -g @lhci/cli
          lhci autorun          

Track real user metrics

Synthetic tests don’t capture real-world variance. Track actual users:

// Performance monitoring service
class PerformanceMonitor {
  constructor(endpoint) {
    this.endpoint = endpoint;
    this.metrics = {};
    this.initWebVitals();
  }

  initWebVitals() {
    onCLS(this.handleMetric.bind(this));
    onFID(this.handleMetric.bind(this));
    onLCP(this.handleMetric.bind(this));
    onINP(this.handleMetric.bind(this));
  }

  handleMetric(metric) {
    this.metrics[metric.name] = metric.value;
    
    // Send to analytics
    this.send({
      name: metric.name,
      value: metric.value,
      rating: metric.rating, // 'good', 'needs-improvement', or 'poor'
      delta: metric.delta,
      id: metric.id,
      url: window.location.href,
      userAgent: navigator.userAgent
    });
  }

  send(data) {
    const body = JSON.stringify(data);
    navigator.sendBeacon(this.endpoint, body);
  }
}

// Initialize on app load
new PerformanceMonitor('/api/metrics');

Set performance budgets and alert when they’re exceeded. A 10% degradation might seem small, but it compounds. Catch it early, fix it fast, and keep your Core Web Vitals in the green zone.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.