Skip to main content

Core Web Vitals: Optimize Your Site Before Google and Users Hate It

Core Web Vitals: Optimize Your Site Before Google and Users Hate It
tips#performance#seo#core-web-vitals#lighthouse+4 more

Core Web Vitals: Optimize Your Site Before Google and Users Hate It

These metrics aren't just numbers—they're how Google understands your user experience. Learn how to optimize CLS, LCP, and INP to keep both Google and your users happy.

Mohammad Alhabil

Author

December 26, 2024
6 min read
~1200 words

Core Web Vitals: Optimize Your Site Before Google and Users Hate It

These metrics aren't just numbers—they're Google's way of understanding your site's user experience!

You've heard about site speed, but Google has 3 main metrics (Core Web Vitals) that actually measure: Is the visitor happy or frustrated?

The Three Core Web Vitals

MetricWhat It MeasuresGood Score
CLSVisual stability< 0.1
LCPLoading performance< 2.5s
INPInteractivity< 200ms

1️⃣ CLS (Cumulative Layout Shift)

What it means: Elements suddenly moving while you're browsing.

You open a website... suddenly the banner drops down, the button moves! This usually happens because of:

  • Images or videos without fixed dimensions
  • Content loading and changing suddenly
  • Ads or embeds injecting themselves

The Problem

<!-- ❌ BAD - No dimensions, causes layout shift -->
<img src="/hero.jpg" alt="Hero" />

When the image loads, it pushes everything down!

The Solution

<!-- ✅ GOOD - Fixed dimensions prevent shifts -->
<img 
  src="/hero.jpg" 
  alt="Hero"
  width="1200"
  height="600"
/>

For Dynamic Content: Use Aspect Ratio Skeletons

For components loading data asynchronously, use aspect-ratio in CSS as a skeleton to reserve space:

// Component with skeleton placeholder
function ProductCard({ product }: { product?: Product }) {
  if (!product) {
    return (
      <div className="product-skeleton">
        {/* Image placeholder with fixed aspect ratio */}
        <div 
          className="bg-gray-200 animate-pulse"
          style={{ aspectRatio: '4/3' }}
        />
        {/* Text placeholders */}
        <div className="h-4 bg-gray-200 animate-pulse mt-2 w-3/4" />
        <div className="h-4 bg-gray-200 animate-pulse mt-1 w-1/2" />
      </div>
    );
  }

  return (
    <div className="product-card">
      <img 
        src={product.image}
        alt={product.name}
        style={{ aspectRatio: '4/3' }}
        className="object-cover"
      />
      <h3>{product.name}</h3>
      <p>{product.price}</p>
    </div>
  );
}
/* CSS approach */
.image-container {
  aspect-ratio: 16 / 9;
  background: #e5e7eb;
}

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

More CLS Fixes

IssueSolution
Web fonts causing flashUse font-display: swap with fallback
Ads/embedsReserve space with min-height
Dynamic contentUse skeleton loaders
AnimationsUse transform instead of top/left

2️⃣ LCP (Largest Contentful Paint)

What it means: The time it takes for the largest element on the page to appear on screen.

Sometimes the page feels empty for a long time, and the large element (image, video, or header) doesn't show quickly.

Common Causes

In many cases, the problem is CSS files or web fonts (Custom Fonts) that block rendering. Speed isn't just about compressing files—it's about prioritizing what loads first.

Solutions

1. Use Modern Image Formats

Replace PNG/JPEG with WebP or AVIF—they're smaller and faster:

// Next.js automatically optimizes
import Image from 'next/image';

<Image
  src="/hero.jpg"
  alt="Hero"
  width={1200}
  height={600}
  priority  // Preload this image
  placeholder="blur"
/>
<!-- HTML with fallbacks -->
<picture>
  <source srcset="/hero.avif" type="image/avif" />
  <source srcset="/hero.webp" type="image/webp" />
  <img src="/hero.jpg" alt="Hero" width="1200" height="600" />
</picture>

2. Preload Critical Resources

Use <link rel="preload"> in the header for the most important image or font, so the browser loads it first:

<head>
  <!-- Preload hero image -->
  <link 
    rel="preload" 
    as="image" 
    href="/hero.webp"
    type="image/webp"
  />
  
  <!-- Preload critical font -->
  <link 
    rel="preload" 
    as="font" 
    href="/fonts/Inter.woff2"
    type="font/woff2"
    crossorigin
  />
</head>

3. Optimize Fonts

@font-face {
  font-family: 'Inter';
  src: url('/fonts/Inter.woff2') format('woff2');
  font-display: swap;  /* Show fallback immediately */
  font-weight: 400 700;
}

LCP Optimization Checklist

ResourceOptimization
ImagesWebP/AVIF, lazy load below-fold, preload hero
FontsPreload, font-display: swap, subset
CSSInline critical CSS, defer non-critical
JavaScriptDefer/async, code split

3️⃣ INP (Interaction to Next Paint)

What it means: How fast the site responds after a user interacts with it.

You clicked "Add to Cart" and nothing happened for a while? This is usually because of large JavaScript blocking the main thread.

📝 Note: Google used to use FID (First Input Delay) to measure only the first interaction. Now they've replaced it with INP, which measures all interactions for a more accurate picture of user experience.

Solutions

1. Code Splitting

// React.lazy for component splitting
const HeavyComponent = React.lazy(() => import('./HeavyComponent'));

function App() {
  return (
    <Suspense fallback={<Loading />}>
      <HeavyComponent />
    </Suspense>
  );
}
// Next.js dynamic imports
import dynamic from 'next/dynamic';

const Chart = dynamic(() => import('./Chart'), {
  loading: () => <ChartSkeleton />,
  ssr: false,  // Don't render on server if not needed
});

2. Defer Non-Critical Work

Use requestIdleCallback or setTimeout to delay tasks that aren't immediately necessary:

// Defer analytics initialization
useEffect(() => {
  // Use requestIdleCallback for non-critical work
  if ('requestIdleCallback' in window) {
    requestIdleCallback(() => {
      initAnalytics();
      loadThirdPartyScripts();
    });
  } else {
    // Fallback for Safari
    setTimeout(() => {
      initAnalytics();
      loadThirdPartyScripts();
    }, 1);
  }
}, []);

3. useTransition for Non-Blocking Updates

In React, use useTransition to update parts of the UI (like search results) without freezing user input:

function SearchComponent() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);
  const [isPending, startTransition] = useTransition();

  const handleSearch = (e: React.ChangeEvent<HTMLInputElement>) => {
    const value = e.target.value;
    
    // Update input immediately (high priority)
    setQuery(value);
    
    // Update results with lower priority (won't block typing)
    startTransition(() => {
      const filtered = filterLargeDataset(value);
      setResults(filtered);
    });
  };

  return (
    <div>
      <input 
        value={query}
        onChange={handleSearch}
        placeholder="Search..."
      />
      
      {isPending && <span>Searching...</span>}
      
      <ul>
        {results.map(item => (
          <li key={item.id}>{item.name}</li>
        ))}
      </ul>
    </div>
  );
}

ℹ️ TBT (Total Blocking Time)

Not an official Core Web Vital, but an essential metric that helps you understand INP problems. It appears in Lighthouse/PageSpeed and measures the time when the main thread is busy and can't respond quickly.

Solutions

1. Use Web Workers for Heavy CPU Work

Move heavy computation off the main thread:

// worker.ts
self.onmessage = (e: MessageEvent) => {
  const result = heavyComputation(e.data);
  self.postMessage(result);
};

// Component
const worker = new Worker(new URL('./worker.ts', import.meta.url));

worker.postMessage(largeData);
worker.onmessage = (e) => {
  setResult(e.data);  // Main thread stays free
};

2. Use OffscreenCanvas for Heavy Drawing

If you're dealing with large drawing operations:

// Move canvas rendering to worker
const offscreen = canvas.transferControlToOffscreen();
worker.postMessage({ canvas: offscreen }, [offscreen]);

Tools to Analyze and Improve Core Web Vitals

ToolBest For
🔍 Google LighthouseBuilt into Chrome DevTools, comprehensive audits
📊 PageSpeed InsightsReal-world data + lab data
🧩 Web Vitals ExtensionReal-time monitoring while browsing
⚛️ React ProfilerComponent-level performance analysis
📦 Webpack Bundle AnalyzerFind what's bloating your bundle
📈 Google AnalyticsTrack real user metrics

Quick Lighthouse Check

  1. Open Chrome DevTools (F12)
  2. Go to Lighthouse tab
  3. Select "Performance"
  4. Click "Analyze page load"

Add Web Vitals to Your App

// Install: npm install web-vitals
import { onCLS, onLCP, onINP } from 'web-vitals';

onCLS(console.log);
onLCP(console.log);
onINP(console.log);

// Or send to analytics
function sendToAnalytics(metric) {
  gtag('event', metric.name, {
    value: Math.round(metric.value),
    event_label: metric.id,
  });
}

onCLS(sendToAnalytics);
onLCP(sendToAnalytics);
onINP(sendToAnalytics);

Quick Reference

IssueMetricFix
Elements jumping aroundCLSAdd width/height, use aspect-ratio
Page feels emptyLCPPreload hero, use WebP, inline critical CSS
Clicks feel slowINPCode split, useTransition, defer scripts
Everything is slowTBTWeb Workers, reduce JS bundle

What's the biggest Core Web Vitals problem you've faced? And how did you solve it?

Interfaces you love, code you understand. 💡

Topics covered

#performance#seo#core-web-vitals#lighthouse#react#nextjs#optimization#google

Found this article helpful?

Share it with your network and help others learn too!

Mohammad Alhabil

Written by Mohammad Alhabil

Frontend Developer & Software Engineer passionate about building beautiful and functional web experiences. I write about React, Next.js, and modern web development.