Web Performance Optimization 2026 — Core Web Vitals, LCP, CLS, and INP Guide

Sanjeev SharmaSanjeev Sharma
5 min read

Advertisement

Introduction

Why This Matters

Google uses Core Web Vitals as a ranking signal. In 2026, INP (Interaction to Next Paint) replaced FID as the interactivity metric. Sites that miss the "Good" thresholds for LCP, CLS, and INP rank lower and convert worse — performance is a business metric.

Core Web Vitals Thresholds

MetricGoodNeeds ImprovementPoor
LCP (Largest Contentful Paint)<= 2.5s<= 4s> 4s
CLS (Cumulative Layout Shift)<= 0.1<= 0.25> 0.25
INP (Interaction to Next Paint)<= 200ms<= 500ms> 500ms

Measuring Performance

Start with measurement before optimizing:

# Install web-vitals library
npm install web-vitals
// src/lib/vitals.ts
import { onCLS, onINP, onLCP, onFCP, onTTFB } from 'web-vitals'
 
function sendToAnalytics(metric: { name: string; value: number; rating: string }) {
  fetch('/api/vitals', {
    method: 'POST',
    body: JSON.stringify(metric),
    headers: { 'Content-Type': 'application/json' },
  })
}
 
export function reportWebVitals() {
  onCLS(sendToAnalytics)
  onINP(sendToAnalytics)
  onLCP(sendToAnalytics)
  onFCP(sendToAnalytics)
  onTTFB(sendToAnalytics)
}
// app/layout.tsx (Next.js)
'use client'
import { useEffect } from 'react'
import { reportWebVitals } from '@/lib/vitals'
 
export function VitalsReporter() {
  useEffect(() => { reportWebVitals() }, [])
  return null
}

Improving LCP

LCP is most often the hero image or largest heading. Target: under 2.5 seconds.

// 1. Preload the LCP image
// app/layout.tsx
export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html>
      <head>
        <link
          rel="preload"
          href="/images/hero.webp"
          as="image"
          fetchPriority="high"
        />
      </head>
      <body>{children}</body>
    </html>
  )
}
 
// 2. Use next/image with priority
import Image from 'next/image'
 
export function Hero() {
  return (
    <Image
      src="/images/hero.webp"
      alt="Hero"
      width={1200}
      height={630}
      priority          // adds fetchpriority="high" + preload link
      sizes="100vw"
    />
  )
}
 
// 3. Use a CDN with Cache-Control: public, max-age=31536000, immutable

Eliminating CLS

Layout shift happens when resources load without reserved dimensions:

// Always set width/height on images
<Image src={img} alt="" width={800} height={450} />
 
// Reserve space for dynamic content
function SkeletonCard() {
  return (
    <div className="h-48 bg-gray-100 rounded-lg animate-pulse" aria-hidden />
  )
}
 
// Avoid inserting content above existing content
// BAD: inserting a banner at the top of the page after load
// GOOD: reserve the banner slot with min-height in the HTML
 
// Use CSS aspect-ratio for responsive containers
/* Prevent font FOUT causing layout shift */
@font-face {
  font-family: 'Inter';
  src: url('/fonts/inter.woff2') format('woff2');
  font-display: swap;  /* or 'optional' for zero CLS */
}
 
/* Size-adjust compensates for font metric differences */

Improving INP

INP measures the worst interaction delay. Fix long tasks first:

// Break up long synchronous work with scheduler.postTask
async function processLargeList(items: Item[]) {
  const CHUNK_SIZE = 50
  const results: Result[] = []
 
  for (let i = 0; i &lt; items.length; i += CHUNK_SIZE) {
    const chunk = items.slice(i, i + CHUNK_SIZE)
    results.push(...processChunk(chunk))
 
    // Yield to the browser between chunks
    await scheduler.postTask(() => {}, { priority: 'background' })
  }
 
  return results
}
 
// Use startTransition to defer non-urgent state updates (React)
import { useTransition } from 'react'
 
function SearchInput() {
  const [query, setQuery] = useState('')
  const [results, setResults] = useState([])
  const [isPending, startTransition] = useTransition()
 
  function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
    setQuery(e.target.value)
    startTransition(() => {
      setResults(search(e.target.value))
    })
  }
 
  return (
    <>
      <input value={query} onChange={handleChange} />
      {isPending ? <Spinner /> : <Results data={results} />}
    </>
  )
}

Bundle Optimization

// next.config.ts
import { NextConfig } from 'next'
import bundleAnalyzer from '@next/bundle-analyzer'
 
const withBundleAnalyzer = bundleAnalyzer({
  enabled: process.env.ANALYZE === 'true',
})
 
export default withBundleAnalyzer({
  experimental: {
    optimizePackageImports: ['lodash', 'date-fns', '@radix-ui/react-icons'],
  },
  images: {
    formats: ['image/avif', 'image/webp'],
  },
} satisfies NextConfig)
# Analyze the bundle
ANALYZE=true npm run build

Common Mistakes

  • Not setting priority on the LCP image — it loads lazily by default in next/image
  • Loading Google Fonts with a <link> instead of next/font — causes CLS and extra network round trips
  • Adding event listeners in useEffect without cleanup — causes memory leaks in long sessions
  • Importing an entire utility library (import _ from 'lodash') instead of specific functions
  • Not measuring INP in the field — lab tools like Lighthouse do not reliably capture interaction delays

Best Practices

  • Set priority on the LCP image element and remove it from all others
  • Use next/font to self-host fonts and eliminate layout shift from FOUT
  • Profile INP with Chrome DevTools Performance panel on a real device, not a laptop
  • Set Cache-Control: public, max-age=31536000, immutable on all hashed static assets
  • Run ANALYZE=true npm run build monthly to catch bundle bloat before it ships

Key Takeaways

  • INP replaced FID as the Core Web Vitals interactivity metric — target under 200ms
  • LCP under 2.5s requires a preloaded, priority-fetched, CDN-served hero image
  • CLS under 0.1 requires explicit dimensions on all images and reserved space for dynamic content
  • scheduler.postTask or setTimeout yields between long tasks to keep INP low
  • React startTransition marks state updates as non-urgent, keeping input response fast
  • Bundle analysis with @next/bundle-analyzer reveals which dependencies dominate the JS bundle
  • Field data from Real User Monitoring (RUM) is more accurate than Lighthouse for INP
  • font-display: optional eliminates FOUT at the cost of a first-load blank period for custom fonts

Advertisement

Sanjeev Sharma

Written by

Sanjeev Sharma

Full Stack Engineer · E-mopro

Related reading