How to Optimize Web Performance: Core Web Vitals and Beyond

Achieve sub-2-second load times and pass Core Web Vitals. Covers image optimization, lazy loading, code splitting, CDN strategy, and Lighthouse auditing.

Web performance optimization is a continuous discipline that directly impacts user engagement, conversion, and system scalability. This page guides engineers through the essential practices to measure, diagnose, and improve web performance, with a focus on Core Web Vitals as the foundation and beyond. Use this guide when shipping a new web application, addressing user-reported slow loading, preparing for a major release, or tuning an existing site under load.

Measure Performance from the User’s Perspective

Core Web Vitals—Largest Contentful Paint (LCP), First Input Delay (FID), and Cumulative Layout Shift (CLS)—define the user experience of a web page. To measure them reliably:

import { getCLS, getFID, getLCP } from 'web-vitals';

getLCP((metric) => {
  console.log('LCP:', metric.value);
  // Send metric to analytics backend
  sendToAnalytics('LCP', metric.value);
});

getFID((metric) => {
  console.log('FID:', metric.value);
  sendToAnalytics('FID', metric.value);
});

getCLS((metric) => {
  console.log('CLS:', metric.value);
  sendToAnalytics('CLS', metric.value);
});
npx puppeteer-core --headless=new --timeout=60000 \
  --url=https://example.com \
  --output=performance.json \
  --lcp \
  --fid \
  --cls \
  --throttling=Slow 3G

Each metric has a target threshold:

When any metric fails, use Chrome DevTools to inspect the Performance tab and Lighthouse report for granular insights. The "First Contentful Paint" and "First Meaningful Paint" are distinct: FCP captures the first rendered content, while FMP marks when the main content is rendered.

Optimize Largest Contentful Paint (LCP)

LCP is the most important metric for perceived loading speed. It is triggered when the largest visible element (typically hero image, heading, or article block) becomes visible.

Preload Critical Resources

<link rel="preload" as="image" href="/hero.jpg" type="image/jpeg" />
<link rel="preload" as="font" href="/fonts/inter.woff2" type="font/woff2" crossorigin />
<link rel="preload" as="style" href="/styles/main.css" />
@font-face {
  font-family: 'Inter';
  src: url('/fonts/inter.woff2') format('woff2');
  font-display: swap;
}

Optimize Server Response Time

:method: GET
:path: /
:scheme: https
:authority: example.com
:status: 200

// Pushed resources
:method: GET
:path: /styles/main.css
:scheme: https
:authority: example.com
:status: 200
// Pushed CSS file

gzip on;
gzip_vary on;
gzip_types text/plain text/css text/xml application/xml application/xhtml+xml text/javascript application/javascript;
gzip_comp_level 6;
gzip_min_length 1024;

Prioritize Above-the-Fold Content

<style>
  /* Critical CSS for above-the-fold content */
  .hero { background: #000; color: #fff; }
  .hero h1 { font-size: 3rem; }
  .nav { background: #333; }
</style>
<script src="/js/app.js" defer></script>
<link rel="stylesheet" href="/css/deferred.css" media="print" onload="this.media='all'">

Serve Optimized Images

<picture>
  <source srcset="/hero-480w.webp 480w, /hero-800w.webp 800w, /hero-1200w.webp 1200w"
          sizes="(max-width: 600px) 480px, (max-width: 1000px) 800px, 1200px"
          type="image/webp" />
  <img src="/hero-1200w.jpg" alt="Hero image" width="1200" height="600" />
</picture>
<img src="/thumbnail.jpg" loading="lazy" width="800" height="600" alt="Lazy-loaded image" />

Reduce First Input Delay (FID)

FID measures interactivity. A delay >100ms between a user’s tap/click and the response feels sluggish.

Minimize JavaScript Execution Time

// webpack.config.js
module.exports = {
  entry: './src/index.js',
  output: {
    filename: 'main.[contenthash].js',
    path: path.resolve(__dirname, 'dist'),
    clean: true,
  },
  optimization: {
    splitChunks: {
      chunks: 'all',
    },
    runtimeChunk: 'single',
  },
  plugins: [
    new HtmlWebpackPlugin({
      template: './index.html',
    }),
  ],
};

Defer Non-Critical JavaScript

<script src="/js/vendor.js" defer></script>
<script src="/js/app.js" defer></script>
document.addEventListener('DOMContentLoaded', () => {
  // Initialize app
  initApp();
});

// Or use async for independent scripts
<script src="/js/analytics.js" async></script>

Avoid Long Tasks

function processLargeDataset(data) {
  const startTime = performance.now();

  function processChunk() {
    let startTimeChunk = performance.now();
    const chunkSize = 1000;

    for (let i = 0; i < chunkSize; i++) {
      // Process 1000 items
      processItem(data[i]);
    }

    if (data.length > 0) {
      requestIdleCallback(processChunk, { timeout: 100 });
    } else {
      console.log(`Processing completed in ${performance.now() - startTime}ms`);
    }
  }

  requestIdleCallback(processChunk, { timeout: 100 });
}
performance.mark('start-processing');
processLargeDataset(data);
performance.mark('end-processing');
performance.measure('processing-time', 'start-processing', 'end-processing');

Minimize Cumulative Layout Shift (CLS)

CLS measures visual stability. A layout shift occurs when elements move unexpectedly after load.

Set Explicit Dimensions for Images and Embeds

<img src="/hero.jpg" width="800" height="600" alt="Hero" loading="lazy" />
<iframe src="/video.html" width="640" height="360" frameborder="0"></iframe>
.image-container {
  aspect-ratio: 16 / 9;
  overflow: hidden;
}

.image-container img {
  width: 100%;
  height: 100%;
  object-fit: cover;
}

Reserve Space for Dynamic Content

<div class="hero-placeholder" style="height: 400px; background: #ccc;">
  <h1>Loading…</h1>
  <p>Content will appear shortly.</p>
</div>

<script>
  fetch('/api/hero-content')
    .then(res => res.json())
    .then(data => {
      const container = document.querySelector('.hero-placeholder');
      container.innerHTML = `<h1>${data.title}</h1><p>${data.description}</p>`;
      container.style.height = 'auto';
    });
</script>

Avoid Layout Thrashing

function updateLayout() {
  // Update multiple elements
  element1.style.transform = 'translateX(100px)';
  element2.style.opacity = 0.5;
  element3.style.width = '300px';
}

// Batch updates
requestAnimationFrame(updateLayout);

Defer Font Loading

@font-face {
  font-family: 'Inter';
  src: url('/fonts/inter.woff2') format('woff2');
  font-display: swap;
}
const fontLoader = new FontFaceObserver('Inter');
fontLoader.load().then(() => {
  document.documentElement.classList.add('fonts-loaded');
});

Monitor Performance in Production

function sendToAnalytics(metricName, value) {
  const payload = {
    metric: metricName,
    value: value,
    url: window.location.href,
    timestamp: Date.now(),
    userAgent: navigator.userAgent,
  };

  navigator.sendBeacon('/api/performance', JSON.stringify(payload));
}
# .github/workflows/lighthouse.yml
name: Lighthouse
on: [push]

jobs:
  lighthouse:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-node@v3
        with:
          node-version: 18
      - run: npm install
      - run: npm run build
      - run: npx lighthouse-ci --config=lighthouse-ci.config.js
// lighthouse-ci.config.js
module.exports = {
  ci: {
    url: [
      'https://example.com/',
      'https://example.com/blog',
      'https://example.com/about'
    ],
    collect: {
      numberOfRuns: 3,
      settings: {
        formFactor: 'desktop',
        throttling: {
          method: 'simulate',
          cpuSlowdownMultiplier: 4,
          requestLatencyMs: 150,
          downloadThroughputKbps: 1000,
          uploadThroughputKbps: 750,
        },
        emulatedFormFactor: 'desktop',
      },
    },
    upload: {
      target: 'temporary-public-storage',
    },
    assert: {
      assertions: {
        'categories:performance': ['>=90'],
        'categories:accessibility': ['>=90'],
        'categories:seo': ['>=90'],
        'metrics:largest-contentful-paint': ['<=2500'],
        'metrics:interactive': ['<=1000'],
      },
    },
  },
};

Troubleshoot Common Pitfalls

Silent failures:

Always measure performance after every deployment, and treat Core Web Vitals as production SLIs.

This page was rewritten on 15 October 2026. It replaced a templated version that was not specific to its own title. How these pages are written and checked. If anything here is wrong, tell us at [email protected] and we will correct it.