WEIRDSOFT
Back to blog

Optimizing Core Web Vitals in 2026

Understanding Core Web Vitals in 2026

Core Web Vitals are not new in 2026, but the landscape has shifted significantly. Google has refined how each metric is measured. Browsers have added new APIs. Frameworks have built in optimizations that previously required manual configuration.

As of 2026, the Core Web Vitals remain:

  • LCP (Largest Contentful Paint) -- perceived loading speed
  • INP (Interaction to Next Paint) -- responsiveness (replaced FID in March 2024)
  • CLS (Cumulative Layout Shift) -- visual stability

The thresholds are unchanged from the 2024 update:

Metric Good Needs Improvement Poor
LCP <= 2.5s 2.5s - 4.0s > 4.0s
INP <= 200ms 200ms - 500ms > 500ms
CLS <= 0.1 0.1 - 0.25 > 0.25

But the measurement methodology has matured. Google now weights field data more heavily than lab data in ranking. The CrUX (Chrome User Experience Report) dataset covers more dimensions. And INP is now fully phased in, replacing FID entirely.

The practical implication is that you cannot optimize for a simulated environment alone -- you must measure and optimize for real users on real devices under real network conditions.

LCP: Largest Contentful Paint

LCP measures the time from page load start to when the largest content element (image, text block, or video poster) becomes visible. In 2026, most LCP elements are images, followed by text blocks rendered by frameworks.

Images: The Most Common Culprit

The hero image is responsible for the LCP element on the majority of pages. Fixing image LCP requires attention to four areas: discovery, delivery, format, and size.

Discovery: The browser must find the image. If it is buried in a lazy-loaded component or referenced in a CSS background-image loaded late, the browser discovers it too late.

// Next.js -- the priority prop tells Next.js to add a preload link
import Image from 'next/image';

export default function Hero() {
  return (
    <Image
      src="/hero.webp"
      alt="Product showcase"
      width={1200}
      height={630}
      priority
      quality={85}
    />
  );
}

Delivery: The image must arrive quickly. A CDN with edge caching is non-negotiable. Configure your CDN to cache images aggressively -- a Cache-Control header of public, max-age=31536000, immutable combined with content-based image URLs ensures that repeat visits are instant.

Format: Serve AVIF or WebP based on browser support. Next.js next/image handles content negotiation automatically when you configure a remote image loader that supports format conversion.

// next.config.ts
const config = {
  images: {
    formats: ['image/avif', 'image/webp'],
    deviceSizes: [640, 750, 828, 1080, 1200, 1920],
  },
};

Size: Keep hero images under 100 KB for mobile and under 200 KB for desktop. This is achievable with AVIF at quality 70-80 for most photographs. Use responsive image sizes to avoid serving desktop-sized images to mobile devices. The sizes attribute on the Image component controls which source the browser selects:

<Image
  src="/hero.avif"
  alt="Hero"
  fill
  sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
  priority
/>

Fonts: The Hidden LCP Factor

Fonts affect LCP indirectly. When a web font loads and swaps into place, it can delay the rendering of text-based LCP elements. The browser must download the font before it can render text with that font family.

/* Optimize font loading */
@font-face {
  font-family: 'Inter';
  src: url('/fonts/Inter-Regular.woff2') format('woff2');
  font-display: swap;
  font-weight: 400;
  font-style: normal;
  unicode-range: U+0000-00FF; /* Limit character set to Latin */
}

Use font-display: optional for body text. This gives the browser a 100ms block period and then renders with a fallback if the font has not loaded. The effect on LCP is significant -- text becomes visible immediately rather than waiting for a network request.

For headings where the brand font matters, use font-display: swap with a close-matching fallback and size-adjust to minimize layout shift:

@font-face {
  font-family: 'Brand Headline';
  src: url('/fonts/Headline.woff2') format('woff2');
  font-display: swap;
  size-adjust: 95%;
}

Tools like Fontaine by UnJS can automatically generate font metric overrides by analyzing the font files, saving you the trial-and-error of manual adjustment.

TTFB: The Foundation

Time to First Byte (TTFB) is not a Core Web Vital, but it constrains all other loading metrics. If TTFB is 1.5s, you have 1s remaining to hit the LCP target.

In Next.js, optimize TTFB through:

  • Edge or serverless deployment close to users -- deploy to multiple regions (Vercel, Cloudflare Workers, AWS Lambda@Edge)
  • Static generation (SSG) where possible -- pre-rendered HTML served from CDN edge with zero server execution time
  • Incremental Static Regeneration (ISR) -- cache pages and revalidate asynchronously, combining the speed of static with the freshness of dynamic
  • Streaming and React Server Components -- send HTML progressively without waiting for all data fetches
// ISR for content pages -- instant TTFB with fresh data
export const revalidate = 3600; // Revalidate every hour

async function getPageData() {
  const data = await fetchCMS();
  return data;
}

React Server Components in Next.js 15+ eliminate large bundles of client-side JavaScript, reducing both TTFB (smaller HTML) and LCP (less JavaScript to parse before rendering):

// This component runs on the server -- zero client JS
async function ProductDescription({ id }: { id: string }) {
  const product = await db.products.findById(id);
  return (
    <article>
      <h1>{product.name}</h1>
      <p>{product.description}</p>
    </article>
  );
}

INP: Interaction to Next Paint

INP replaced FID in March 2024. Unlike FID, which only measured the delay before the event handler started running, INP measures the full duration from interaction to the next frame being painted. This includes event handler execution time, re-render time, and paint time.

The Anatomy of a Slow Interaction

A slow interaction follows this timeline:

  1. User taps or clicks (browser event dispatch adds ~10-50ms)
  2. Event handler starts (potentially delayed by long tasks ahead in the queue)
  3. Event handler executes (handler code, state updates, re-renders)
  4. Browser paints the next frame

INP captures all four stages. Optimizing INP means reducing each stage.

Long Tasks: The Main Thread Blockers

Long tasks -- any JavaScript execution exceeding 50ms -- block the main thread and delay interaction handling. Profile with the Long Tasks API:

const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    console.warn('Long task detected:', {
      duration: entry.duration,
      startTime: entry.startTime,
    });
  }
});
observer.observe({ entryTypes: ['longtask'] });

Deploy this observer in your staging environment and aggregate the results. Long tasks are invisible to Lighthouse but devastating to real user experience.

Breaking Up Long Tasks with scheduler.yield()

The scheduler.yield() API (available since Chrome 2024) lets you voluntarily yield the main thread, allowing pending interactions to be processed:

async function processSearchResults(items: SearchResult[]) {
  const batchSize = 50;

  for (let i = 0; i < items.length; i += batchSize) {
    const batch = items.slice(i, i + batchSize);

    // Process a batch synchronously
    for (const item of batch) {
      renderResult(item);
    }

    // Yield to allow pending user interactions
    if ('scheduler' in window && 'yield' in scheduler) {
      await scheduler.yield();
    } else {
      await new Promise((resolve) => setTimeout(resolve, 0));
    }
  }
}

For frameworks that cannot use async in event handlers, use setTimeout() with 0ms delay or requestAnimationFrame() to defer non-urgent work.

Lazy-Load and Defer Third-Party Scripts

Third-party scripts are the most common cause of poor INP. Analytics, chat widgets, marketing pixels, and A/B testing tools all compete for main thread time.

import dynamic from 'next/dynamic';

const ChatWidget = dynamic(
  () => import('../components/ChatWidget'),
  {
    loading: () => null,
    ssr: false,
  }
);

export default function Layout({ children }: { children: React.ReactNode }) {
  const [showChat, setShowChat] = useState(false);

  useEffect(() => {
    // Load chat after 5 seconds -- well past the critical interaction window
    const timer = setTimeout(() => setShowChat(true), 5000);
    return () => clearTimeout(timer);
  }, []);

  return (
    <>
      {children}
      {showChat && <ChatWidget />}
    </>
  );
}

Audit your third-party scripts quarterly. Remove any that no longer provide clear value. For scripts you must keep, prefer loading them with defer or async and delay their execution until after the page is interactive.

Efficient Event Handlers

Avoid expensive synchronous work in event handlers. Move computations to requestAnimationFrame, setTimeout, or web workers:

// Avoid: synchronous heavy computation in click handler
const handleClick = (item: DataItem) => {
  const processed = heavyComputation(item); // blocks main thread
  setState(processed);
};

// Better: yield before processing
const handleClick = async (item: DataItem) => {
  await scheduler.yield();
  const processed = heavyComputation(item);
  setState(processed);
};

// Best: move to a web worker
const handleClick = (item: DataItem) => {
  worker.postMessage(item);
  worker.onmessage = (event) => {
    setState(event.data);
  };
};

React Compiler and Automatic Optimization

The React Compiler (stable since React 19) automatically memoizes components and hooks, reducing unnecessary re-renders that contribute to INP. If you have not migrated to the React Compiler, that single change can reduce INP by 30-50% on interaction-heavy pages.

// next.config.ts -- enable the React Compiler
const config = {
  reactCompiler: true,
};

CLS: Cumulative Layout Shift

CLS measures unexpected layout shifts during the page lifecycle. A shift occurs when a visible element changes position between frames. In 2026, the common causes are unchanged: images without dimensions, dynamic content injection, and font swaps.

Images: Always Reserve Space

Every image must have explicit dimensions so the browser reserves space before the image loads:

// Good: explicit width and height
<Image src="/photo.jpg" width={800} height={450} alt="" />

// Good: aspect-ratio container for dynamic images
<div style={{ aspectRatio: '16 / 9', maxWidth: 800 }}>
  {image && <Image src={image.src} fill alt={image.alt} />}
</div>

For images loaded through a CMS where dimensions are not known at build time, fetch the dimensions from the image API or use a proxy that returns dimensions:

async function OptimizedImage({ src, alt }: { src: string; alt: string }) {
  const { width, height } = await getImageDimensions(src);
  return <Image src={src} width={width} height={height} alt={alt} />;
}

The CSS aspect-ratio property is your best tool for sizing unknown-content containers. Use it everywhere you have dynamic media:

.video-container {
  aspect-ratio: 16 / 9;
}

.ad-container {
  aspect-ratio: 4 / 3;
  min-height: 250px;
}

Fonts: Minimize Layout Shift from Swap

When a web font loads and swaps with the fallback, the text reflows if the metrics differ. This causes CLS. Mitigation strategies:

  1. Use font-display: optional for body text -- avoids swap entirely after a brief block period
  2. Use size-adjust in @font-face to match fallback metrics
  3. Use an invisible font as a fallback that triggers the same layout as the web font
  4. Inline critical fonts in the HTML for above-the-fold text
/* Match fallback and web font metrics */
@font-face {
  font-family: 'Custom Body';
  src: url('/fonts/CustomBody.woff2') format('woff2');
  font-display: swap;
  size-adjust: 98%;
  ascent-override: 90%;
  descent-override: 20%;
}

The size-adjust, ascent-override, and descent-override properties are part of the CSS Fonts Level 4 specification. They are supported in all modern browsers and allow fine-grained control over how a font's metrics map to the layout. Tools like Fontaine by UnJS can generate these values automatically by analyzing your font files.

Dynamic Content and Ads

Ads and dynamically injected content are the hardest CLS sources to control because the content is served by a third party. Strategies that work:

  1. Reserve space -- request a minimum ad container size from your ad provider and set those dimensions in CSS
  2. Collapse gracefully -- if an ad does not load, ensure the container collapses without shifting surrounding content
  3. Delay injection -- load ads after the initial layout is stable, typically 2-3 seconds after page load
  4. Sticky ad slots -- position ads in a reserved footer or sidebar area that does not affect the main content flow
.ad-container {
  min-height: 250px;
  min-width: 300px;
  contain: layout style;
}

Next.js Layout and Suspense Boundaries

Next.js 15+ provides built-in CLS protection through its streaming and Suspense architecture. Content that loads asynchronously renders within reserved space:

import { Suspense } from 'react';

export default function Page() {
  return (
    <div>
      <h1>Dashboard</h1>
      <Suspense fallback={<DashboardSkeleton />}>
        <DashboardContent />
      </Suspense>
    </div>
  );
}

// The skeleton matches the final layout dimensions
function DashboardSkeleton() {
  return (
    <div className="grid grid-cols-3 gap-4">
      {Array.from({ length: 3 }).map((_, i) => (
        <div key={i} className="h-48 bg-gray-100 animate-pulse rounded-lg" />
      ))}
    </div>
  );
}

The skeleton dimensions must match the actual content dimensions. If your dashboard renders three columns of widgets, the skeleton must also render three columns. Mismatched skeleton dimensions defeat the purpose and can themselves cause layout shifts.

Lab Data vs. Field Data

Understanding the difference between lab and field data is crucial for effective optimization.

Aspect Lab Data Field Data (RUM)
Source Lighthouse, WebPageTest CrUX, RUM tools
Device Emulated (Moto G4 or Pixel 5) Real user devices
Network Throttled (3G or 4G simulation) Real network conditions
Location Fixed server location Global user distribution
Variability Deterministic Variable by nature
Debuggability High (traces, screenshots) Low (aggregate metrics)
SEO relevance Used for diagnostics Used for ranking

When to Use Lab Data

  • Debugging and identifying the root cause of poor metrics
  • Testing optimizations in a controlled environment
  • Performance budgets in CI/CD pipelines
  • Lighthouse score targets for project requirements

When to Use Field Data

  • Understanding real user experience across devices and geographies
  • Prioritizing optimizations based on actual user impact
  • Monitoring after deployment to catch regressions
  • SEO and ranking assessment (this is what Google uses)

The practical approach: use lab data to identify and fix issues, then validate with field data that the fixes actually improve real user experience. A change that improves Lighthouse by 20 points but does not move the 75th percentile INP in CrUX is not a real improvement.

Monitoring Tools

CrUX (Chrome User Experience Report)

Google's public dataset of real-user metrics. Access through PageSpeed Insights, the CrUX API, or BigQuery. Free and covers millions of URLs. Provides 28-day rolling aggregates at the 75th percentile.

# Query CrUX via the API
curl "https://chromeuxreport.googleapis.com/v1/records:queryRecord" \
  -H "Content-Type: application/json" \
  -d '{
    "formFactor": "PHONE",
    "url": "https://example.com"
  }'

Next.js Speed Insights

Built-in RUM tool when deploying to Vercel. Provides per-page Core Web Vitals with percentile breakdowns. Data is automatically collected from real visitors and displayed in the Vercel dashboard.

Sentry Performance

Provides INP and LCP tracking with error correlation. Useful for understanding which interactions are slow and why. Sentry's span-based tracing lets you drill into specific slow sessions.

Custom RUM with PerformanceObserver

Build your own monitoring with the PerformanceObserver API for full control:

// Custom RUM collector
function initRUM() {
  // LCP observer
  new PerformanceObserver((list) => {
    const entries = list.getEntries();
    const lastEntry = entries[entries.length - 1];
    sendMetric('lcp', lastEntry.startTime);
  }).observe({ type: 'largest-contentful-paint', buffered: true });

  // INP observer
  new PerformanceObserver((list) => {
    for (const entry of list.getEntries()) {
      sendMetric('inp', entry.duration, {
        interactionType: entry.interactionType,
      });
    }
  }).observe({ type: 'first-input', buffered: true });

  // CLS observer
  new PerformanceObserver((list) => {
    for (const entry of list.getEntries()) {
      if (!entry.hadRecentInput) {
        sendMetric('cls', entry.value);
      }
    }
  }).observe({ type: 'layout-shift', buffered: true });
}

Send these metrics to your analytics platform or a dedicated observability tool. The buffered flag ensures you capture metrics even if the observer is registered after the events occur.

Per-Page Optimization Strategies

Different page types have different performance profiles. Optimizing a blog post is not the same as optimizing a dashboard.

Homepage: Image-Heavy

Concern Strategy Expected Impact
LCP Preload hero image, serve AVIF, keep under 100 KB mobile 2-3s improvement
INP Lazy-load secondary content and carousels 100-200ms improvement
CLS Reserve space for hero, testimonials, CTAs 0.05-0.1 improvement

Product Page (E-commerce): Mixed Content

Concern Strategy Expected Impact
LCP Preload first product image, lazy-load gallery 1-2s improvement
INP Debounce filters, defer recommended products 100-300ms improvement
CLS Reserve space for images, reviews, related products 0.05-0.15 improvement

Blog/Article: Text-Heavy

Concern Strategy Expected Impact
LCP Optimize font loading, preload hero image 0.5-1s improvement
INP Lazy-load comments and social widgets 100-200ms improvement
CLS Reserve space for featured images, embeds, code blocks 0.02-0.08 improvement

Dashboard/App: Interaction-Heavy

Concern Strategy Expected Impact
LCP Skeleton screens with reserved dimensions 0.5-1s improvement
INP Web workers for data processing, debounce filters 200-400ms improvement
CLS Reserve space for widgets and data tables 0.03-0.1 improvement

Case Study: E-Commerce Product Page Optimization

A WEIRDSOFT client in the fashion retail space had a product page with poor Core Web Vitals:

Metric Before After Improvement
LCP (p75) 4.8s 1.9s 60%
INP (p75) 380ms 120ms 68%
CLS (p75) 0.28 0.04 86%

What We Changed

LCP fixes:

  • Converted hero image from JPEG to AVIF (85 KB from original 340 KB)
  • Added fetchPriority="high" and Next.js priority prop on the main product image
  • Implemented ISR with 60-second revalidation for instant page loads on repeat visits
  • Configured CDN caching with 1-year immutable headers for all product images
  • Preloaded the product font subset for headings using <link rel="preload">

INP fixes:

  • Lazy-loaded size selector dropdown data (previously loaded eagerly on page mount)
  • Replaced synchronous size change handler with debounced variant selection (150ms debounce)
  • Moved color swatch rendering calculations to a web worker
  • Deferred marketing pixel loading by 7 seconds using requestIdleCallback

CLS fixes:

  • Added aspect-ratio containers to all product images in the gallery
  • Reserved exact pixel dimensions for the recently-viewed slider
  • Set explicit height on the size selector to prevent layout shift from dynamic option sets
  • Added size-adjust to custom font declarations to match fallback metrics

Results

Field data from CrUX showed the improvements persisted across devices and network conditions. The site moved from "Needs Improvement" in all three metrics to "Good" in all three. Organic search traffic increased 23% over the following quarter. Revenue per visitor increased 12% as users encountered a faster, more stable experience.

Action Plan

  1. Measure your current state -- run Lighthouse, check CrUX in PageSpeed Insights, look at your existing RUM data
  2. Identify the worst metric on your highest-traffic page -- fix one thing at a time
  3. Optimize images first -- format, size, preloading. This is the highest-ROI change
  4. Audit third-party scripts -- remove what you can, defer what you cannot
  5. Fix CLS with dimension reservations -- images, fonts, dynamic content
  6. Test in the field -- deploy changes and monitor CrUX and RUM data for two weeks
  7. Iterate -- move to the next page or the next metric

Core Web Vitals optimization is not a one-time project. It is an ongoing practice. The tools and APIs improve every year. The browser capabilities expand. But the principles remain the same: measure, optimize, validate, repeat.