Skip to main content
← All Articles Β· Β· 23 min read

Web Performance and Core Web Vitals 2026: Complete Optimization Guide

Complete guide to Core Web Vitals optimization in 2026. Covers LCP, INP, CLS, TTFB optimization with real code examples. Includes Next.js, React, Vite, Cloudflare Workers, and backend optimization strategies for achieving 90+ Lighthouse scores.

web-performancecore-web-vitalslcpinpclsnextjsreactviteoptimizationperformance

Google’s Core Web Vitals are the most consequential performance metrics for web developers in 2026. These three metrics β€” Largest Contentful Paint (LCP), Interaction to Next Paint (INP), and Cumulative Layout Shift (CLS) β€” directly influence Google search rankings and user experience. A site with poor Core Web Vitals not only ranks lower but loses users who encounter slow, janky, or shifting pages.

This guide covers every aspect of Core Web Vitals optimization: understanding what each metric measures, diagnosing performance problems with modern tooling, and implementing concrete fixes with real code examples across frontend frameworks and backend technologies.


Understanding Core Web Vitals in 2026

The Three Metrics

MetricWhat It MeasuresGoodNeeds WorkPoor
LCP (Largest Contentful Paint)How fast the main content loadsUnder 2.5s2.5s - 4sOver 4s
INP (Interaction to Next Paint)How fast pages respond to interactionsUnder 200ms200ms - 500msOver 500ms
CLS (Cumulative Layout Shift)How stable the page layout isUnder 0.10.1 - 0.25Over 0.25

LCP measures loading performance β€” the time until the largest visible content element (hero image, heading, or main text block) is rendered. INP replaced FID (First Input Delay) in March 2024, measuring responsiveness throughout the entire page lifetime, not just the first interaction. CLS measures visual stability β€” how much the page layout shifts during loading.

What Google Uses for Scoring

Google measures Core Web Vitals from real users through the Chrome User Experience Report (CrUX), not just lab testing. Your Lighthouse score and your actual search ranking can differ significantly.

// Measure Core Web Vitals with web-vitals library
import { onCLS, onINP, onLCP, onFCP, onTTFB } from 'web-vitals';

function sendToAnalytics({ name, value, id, rating, delta }) {
  // rating: 'good' | 'needs-improvement' | 'poor'
  const body = JSON.stringify({
    name,           // Metric name
    value: Math.round(name === 'CLS' ? value * 1000 : value),
    delta: Math.round(name === 'CLS' ? delta * 1000 : delta),
    rating,         // 'good' | 'needs-improvement' | 'poor'
    id,             // Unique identifier for this measurement
    navigationType: performance.getEntriesByType('navigation')[0]?.type,
    url: window.location.href,
  });

  // Use sendBeacon for reliable delivery
  if (navigator.sendBeacon) {
    navigator.sendBeacon('/api/vitals', body);
  }
}

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

LCP Optimization: Loading Performance

LCP is typically caused by one of these: a large hero image, a large text block, or a video poster image. LCP optimization has three phases: eliminate render-blocking resources, optimize the critical rendering path, and serve content from the edge.

Eliminate Render-Blocking Resources

<!-- Before: Render-blocking CSS -->
<link rel="stylesheet" href="/styles.css">
<link rel="stylesheet" href="/fonts.css">

<!-- After: Preload critical CSS, async load non-critical -->
<link rel="preload" href="/critical.css" as="style" onload="this.onload=null;this.rel='stylesheet'">
<link rel="stylesheet" href="/fonts.css" media="print" onload="this.media='all'">
<noscript><link rel="stylesheet" href="/fonts.css"></noscript>

Vite: Inline critical CSS:

// vite.config.js
import { defineConfig } from 'vite';
import { viteInlineSourceMap } from './plugins/inline-critical');

export default defineConfig({
  plugins: [
    {
      name: 'inline-critical',
      transformIndexHtml(html, { bundle }) {
        // Find and inline critical CSS automatically
        const criticalCss = extractCriticalCss(bundle);
        return html.replace(
          '</head>',
          `<style>${criticalCss}</style></head>`
        ).replace(
          /<link[^>]*critical[^>]*>/,
          ''
        );
      },
    },
  ],
});

Image Optimization

// next.config.js - Next.js image optimization
/** @type {import('next').NextConfig} */
const nextConfig = {
  images: {
    formats: ['image/avif', 'image/webp'],
    deviceSizes: [640, 750, 828, 1080, 1200, 1920],
    imageSizes: [16, 32, 48, 64, 96, 128, 256],
    minimumCacheTTL: 31536000, // 1 year
    domains: ['images.example.com'],
  },
};

module.exports = nextConfig;

Responsive images with srcset:

<!-- Explicit srcset for maximum control -->
<img
  src="hero-800.jpg"
  srcset="
    hero-400.jpg 400w,
    hero-800.jpg 800w,
    hero-1200.jpg 1200w,
    hero-1600.jpg 1600w
  "
  sizes="
    (max-width: 640px) 400px,
    (max-width: 1024px) 800px,
    (max-width: 1920px) 1200px,
    1600px
  "
  alt="Hero image"
  width="1600"
  height="900"
  fetchpriority="high"
  loading="eager"
  decoding="async"
>

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

Font Optimization

<!-- Use font-display: optional for non-critical text -->
<!-- Use font-display: swap for body text -->
<!-- Preload critical fonts -->
<link rel="preload" href="/fonts/inter-var.woff2" as="font" type="font/woff2" crossorigin>

<style>
  @font-face {
    font-family: 'Inter';
    font-style: normal;
    font-weight: 100 900;
    font-display: optional; /* Only use if already cached */
    src: url('/fonts/inter-var.woff2') format('woff2-variations');
  }

  @font-face {
    font-family: 'Inter';
    font-style: normal;
    font-weight: 400;
    font-display: swap; /* Show fallback, swap when loaded */
    src: url('/fonts/inter-400.woff2') format('woff2');
  }
</style>

Self-hosted fonts vs Google Fonts:

<!-- Google Fonts (creates DNS lookup and additional requests) -->
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap" rel="stylesheet">

<!-- Self-hosted fonts (eliminates DNS lookup) -->
<link href="/fonts/inter.css" rel="stylesheet">
<!--
  /fonts/inter.css:
  @font-face {
    font-family: 'Inter';
    font-style: normal;
    font-weight: 400;
    font-display: swap;
    src: url('inter-400.woff2') format('woff2');
  }
-->

INP Optimization: Interactivity

INP measures the latency of all page interactions, not just the first one. An interaction is a click, tap, or keyboard input. INP is the worst interaction latency across the entire page session.

Understanding Interaction Components

Every interaction has three latency components:

  1. Input delay β€” Time from user input to event handler start
  2. Processing time β€” Time spent in event handlers, React state updates
  3. Presentation delay β€” Time to render the visual update (paint, layout)
// Diagnose INP issues with Performance Observer
const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (entry.duration > 200) {
      console.log('Slow interaction:', {
        type: entry.name,
        duration: entry.duration,
        startTime: entry.startTime,
        inputDelay: entry.processingStart - entry.startTime,
        processingTime: entry.processingEnd - entry.processingStart,
        presentationDelay: entry.duration - (entry.processingEnd - entry.startTime),
      });
    }
  }
});

observer.observe({ type: 'event', buffered: true, durationThreshold: 100 });

Long Task Breakdown

// React performance profiling
// Add to your React app during development
if (process.env.NODE_ENV === 'development') {
  const whyDidYouRender = require('@welldone-software/why-did-you-render');
  whyDidYouRender(React, {
    trackAllPureComponents: true,
    logOnDifferentValues: true,
  });
}

Optimizing React Rendering

// BEFORE: Component re-renders on every parent re-render
function ExpensiveList({ items }) {
  return (
    <ul>
      {items.map(item => (
        <ExpensiveItem key={item.id} {...item} />
      ))}
    </ul>
  );
}

// AFTER: Memoize expensive components
import { memo, useMemo } from 'react';

const ExpensiveItem = memo(function ExpensiveItem({ id, name, value }) {
  // Heavy computation
  const computed = useMemo(() => expensiveCalculation(value), [value]);

  return (
    <li>
      <span>{name}</span>
      <span>{computed}</span>
    </li>
  );
}, (prev, next) => prev.id === next.id && prev.value === next.value);

// AFTER: Virtualize long lists
import { FixedSizeList } from 'react-window';

function VirtualizedList({ items }) {
  return (
    <FixedSizeList
      height={600}
      itemCount={items.length}
      itemSize={50}
      width="100%"
    >
      {({ index, style }) => (
        <div style={style}>
          <ExpensiveItem {...items[index]} />
        </div>
      )}
    </FixedSizeList>
  );
}

Web Workers for Heavy Computation

// web-workers/data-processor.js
self.addEventListener('message', ({ data }) => {
  const { type, payload } = data;

  switch (type) {
    case 'PROCESS_ENTRIES':
      const result = payload.entries.map(entry => expensiveTransform(entry));
      self.postMessage({ type: 'PROCESSED', result });
      break;

    case 'FILTER_SORT':
      const filtered = payload.data.filter(entry => filterFn(entry, payload.criteria));
      const sorted = filtered.sort((a, b) => compareFn(a, b, payload.sortBy));
      self.postMessage({ type: 'FILTERED_SORTED', result: sorted });
      break;
  }
});

// Main thread usage
const worker = new Worker('/web-workers/data-processor.js');

function processInWorker(data) {
  return new Promise((resolve) => {
    const handler = ({ data }) => {
      if (data.type === 'PROCESSED') {
        worker.removeEventListener('message', handler);
        resolve(data.result);
      }
    };
    worker.addEventListener('message', handler);
    worker.postMessage({ type: 'PROCESS_ENTRIES', payload: { entries: data } });
  });
}

CLS Optimization: Visual Stability

CLS measures unexpected layout shifts. Every element that moves unexpectedly during loading contributes to CLS. The most common culprits are images without dimensions, dynamically injected content, and web fonts causing FOIT/FOUT.

Image Dimension Requirements

<!-- Always specify width and height for images -->
<!-- This reserves space before the image loads -->
<img
  src="product.jpg"
  width="300"
  height="200"
  alt="Product image"
  style="aspect-ratio: 3/2; width: 100%; max-width: 300px;"
>

<!-- For responsive images, use CSS aspect-ratio instead -->
<style>
  .responsive-image {
    width: 100%;
    max-width: 300px;
    aspect-ratio: 3 / 2;
    object-fit: cover;
  }
</style>
<img class="responsive-image" src="product.jpg" alt="Product" loading="lazy">

Reserved Space for Dynamic Content

/* Reserve space for dynamically loaded content */
.ad-container {
  min-height: 250px; /* Reserve height for ad */
  contain: layout;   /* Prevent layout thrashing */
}

/* Reserve space for below-the-fold images */
.image-placeholder {
  background-color: #f0f0f0;
  aspect-ratio: 16 / 9;
}

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

/* Prevent layout shifts from font loading */
@font-face {
  font-family: 'HeadingFont';
  font-display: swap; /* Show fallback, swap when loaded */
}
/* Cookie banners should be positioned to avoid layout shift */
/* Place them fixed at the bottom, not after page content loads */

/* BEFORE: Causes CLS as it pushes content down
.cookie-banner {
  position: relative; <- WRONG
  bottom: 0;
}
*/

/* AFTER: Fixed position doesn't affect document flow
.cookie-banner {
  position: fixed;
  bottom: 0;
  left: 0;
  right: 0;
  z-index: 9999;
}
*/

/* Alternative: Reserve the space before the banner loads */
.cookie-banner-space {
  height: 80px; /* Exact height of banner */
}

Next.js Performance Optimization

App Router Best Practices

// app/layout.js
import { Suspense } from 'react';
import Navigation from '@/components/Navigation';
import Footer from '@/components/Footer';

export default function RootLayout({ children }) {
  return (
    <html lang="en">
      <head>
        {/* Preload critical fonts and resources */}
        <link rel="preload" href="/fonts/inter-400.woff2" as="font" type="font/woff2" crossOrigin="anonymous" />
      </head>
      <body>
        {/* Streaming with Suspense */}
        <Suspense fallback={<NavigationSkeleton />}>
          <Navigation />
        </Suspense>
        <Suspense fallback={<ContentSkeleton />}>
          {children}
        </Suspense>
        <Suspense fallback={null}>
          <Footer />
        </Suspense>
      </body>
    </html>
  );
}

// app/page.js - Server Component by default (fast initial render)
import { db } from '@/lib/db';
import Hero from '@/components/Hero';
import ProductGrid from '@/components/ProductGrid';

export const dynamic = 'force-dynamic'; // Opt out of static if needed
export const revalidate = 60; // Revalidate every 60 seconds (ISR)

async function HomePage() {
  const featuredProducts = await db.getFeaturedProducts({ limit: 8 });

  return (
    <main>
      <Hero />
      <ProductGrid products={featuredProducts} />
    </main>
  );
}

Route-Specific Optimizations

// app/api/products/route.js
import { NextResponse } from 'next/server';

export const dynamic = 'force-dynamic'; // Always fetch fresh data
export const revalidate = 60; // Cache for 60 seconds

export async function GET(request) {
  const products = await fetchProducts();

  // Set cache headers for CDN caching
  return NextResponse.json(products, {
    headers: {
      'Cache-Control': 'public, s-maxage=60, stale-while-revalidate=300',
    },
  });
}

TTFB Optimization: Server Response Time

TTFB measures the time from navigation request to receiving the first byte. A poor TTFB means everything else is delayed.

Backend Performance

# Python FastAPI with async database
from fastapi import FastAPI
from contextlib import asynccontextmanager
import asyncio
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker

# Connection pool settings for production
engine = create_async_engine(
    os.environ['DATABASE_URL'],
    pool_size=20,
    max_overflow=10,
    pool_pre_ping=True,
    pool_recycle=3600,
)

AsyncSessionLocal = sessionmaker(
    engine, class_=AsyncSession, expire_on_commit=False
)

app = FastAPI()

# Async dependency
async def get_db():
    async with AsyncSessionLocal() as session:
        yield session

@app.get('/api/products')
async def get_products(db: AsyncSession = Depends(get_db)):
    result = await db.execute(
        select(Product).options(
            joinedload(Product.category),
            joinedload(Product.images),
        ).limit(20)
    )
    products = result.scalars().unique().all()
    return products

Edge Caching for TTFB

// Cloudflare Worker to cache API responses at the edge
export default {
  async fetch(request, env) {
    const url = new URL(request.url);

    // Only cache GET requests to /api/ endpoints
    if (request.method !== 'GET' || !url.pathname.startsWith('/api/')) {
      return fetch(request);
    }

    const cacheKey = `https://api.example.com${url.pathname}${url.search}`;
    const cache = caches.default;

    let response = await cache.match(cacheKey);

    if (!response) {
      response = await fetch(request);

      if (response.ok) {
        // Clone response to store in cache
        const newResponse = new Response(response.body, response);
        newResponse.headers.set('Cache-Control', 'public, max-age=60, stale-while-revalidate=300');
        newResponse.headers.set('CF-Cache-Status', 'MISS');
        await cache.put(cacheKey, newResponse.clone());
      }
    } else {
      // Add header to indicate cache hit
      response = new Response(response.body, response);
      response.headers.set('CF-Cache-Status', 'HIT');
    }

    return response;
  },
};

Real User Monitoring Setup

Custom RUM Implementation

//rum.js - Real User Monitoring for Core Web Vitals
import { onCLS, onINP, onLCP, onFCP, onTTFB } from 'web-vitals';

class CoreWebVitalsReporter {
  constructor(endpoint, sampleRate = 1.0) {
    this.endpoint = endpoint;
    this.sampleRate = sampleRate;
    this.queue = [];
    this.flushInterval = setInterval(() => this.flush(), 5000);
  }

  shouldSample() {
    return Math.random() < this.sampleRate;
  }

  report({ name, value, rating, id }) {
    if (!this.shouldSample()) return;

    const record = {
      metric: name,
      value: Math.round(name === 'CLS' ? value * 1000 : value),
      rating, // 'good' | 'needs-improvement' | 'poor'
      id,
      timestamp: Date.now(),
      url: window.location.href,
      userAgent: navigator.userAgent,
      connection: navigator.connection?.effectiveType,
      deviceMemory: navigator.deviceMemory,
    };

    this.queue.push(record);

    // Immediate flush for poor metrics
    if (rating === 'poor') {
      this.flush();
    }
  }

  flush() {
    if (this.queue.length === 0) return;

    const batch = this.queue.splice(0, this.queue.length);
    const blob = new Blob(
      [batch.map(r => JSON.stringify(r)).join('\n')],
      { type: 'application/json' }
    );

    if (navigator.sendBeacon) {
      navigator.sendBeacon(this.endpoint, blob);
    } else {
      fetch(this.endpoint, {
        method: 'POST',
        body: blob,
        keepalive: true,
      }).catch(() => {});
    }
  }
}

const reporter = new CoreWebVitalsReporter('/api/vitals', 0.1); // 10% sample rate

onCLS(reporter.report.bind(reporter));
onINP(reporter.report.bind(reporter));
onLCP(reporter.report.bind(reporter));
onFCP(reporter.report.bind(reporter));
onTTFB(reporter.report.bind(reporter));

Performance Budgets

// . Lighthouse CI configuration with performance budgets
// lighthouserc.js
module.exports = {
  ci: {
    collect: {
      url: [
        'https://example.com/',
        'https://example.com/products',
        'https://example.com/about',
      ],
      numberOfRuns: 3,
    },
    assert: {
      assertions: {
        'categories:performance': ['error', { minScore: 0.9 }],
        'first-contentful-paint': ['error', { maxNumericValue: 1800 }],
        'largest-contentful-paint': ['error', { maxNumericValue: 2500 }],
        'interactive': ['error', { maxNumericValue: 3000 }],
        'total-blocking-time': ['error', { maxNumericValue: 200 }],
        'cumulative-layout-shift': ['error', { maxNumericValue: 0.1 }],
        'server-response-time': ['error', { maxNumericValue: 800 }],
      },
    },
    upload: {
      target: 'lhci',
      serverBaseUrl: process.env.LHCI_SERVER_URL,
      token: process.env.LHCI_BUILD_TOKEN,
    },
  },
};

Core Web Vitals Optimization Checklist

LCP (Largest Contentful Paint)

  • Preload the LCP image with <link rel="preload" as="image">
  • Serve images in next-gen formats (AVIF, WebP)
  • Use explicit width and height on all images
  • Eliminate render-blocking CSS and JavaScript
  • Use a CDN with edge caching
  • Optimize server response time (TTFB under 800ms)
  • Self-host fonts and use font-display: swap

INP (Interaction to Next Paint)

  • Break up long tasks with setTimeout or requestIdleCallback
  • Move heavy computation to Web Workers
  • Memoize expensive React components with memo and useMemo
  • Virtualize long lists with react-window or similar
  • Defer non-critical JavaScript with dynamic imports
  • Avoid layout thrashing in event handlers

CLS (Cumulative Layout Shift)

  • Always set width and height on images and videos
  • Reserve space for dynamically injected content (ads, banners)
  • Use font-display: swap to prevent font FOUT shifts
  • Set min-height on containers that load content asynchronously
  • Avoid inserting content above existing content

General

  • Set up Real User Monitoring (RUM) with web-vitals library
  • Set up Lighthouse CI to catch regressions in PRs
  • Test on real devices, not just emulators
  • Monitor CrUX data in Google Search Console
  • Profile interactions in Chrome DevTools Performance panel

Conclusion

Core Web Vitals optimization in 2026 requires understanding that these metrics are not just technical numbers but real user experiences. LCP is about how quickly users can see meaningful content. INP is about whether the page feels responsive when they interact with it. CLS is about whether the page jumps around as it loads.

The most effective optimization strategy is to first measure in production with real user monitoring, then identify the biggest opportunities, then implement targeted fixes. Lighthouse in development gives you lab data; CrUX gives you field data. Both matter.

Start by getting your LCP under 2.5 seconds β€” this typically has the biggest impact on user experience and SEO. Use preloads for critical images, optimize your server response time, and serve content from a CDN close to your users. Then tackle INP and CLS with the specific techniques in this guide.

Free Download

50 CLI Commands That Save Hours Every Week

The exact Git shortcuts, file search tricks, Docker commands, and productivity hacks that senior developers use daily. Compiled from 5+ years of shipping production code.

  • Git mastery (10 cmds)
  • File & search wizardry
  • Docker & DevOps tricks
  • Process & network tools
  • Productivity boosters
  • macOS & Linux install guide

No spam. 1 email/week max. Unsubscribe anytime.

Found this guide useful? Check out our free developer tools.