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

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
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
| Metric | What It Measures | Good Score |
|---|---|---|
| CLS | Visual stability | < 0.1 |
| LCP | Loading performance | < 2.5s |
| INP | Interactivity | < 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
| Issue | Solution |
|---|---|
| Web fonts causing flash | Use font-display: swap with fallback |
| Ads/embeds | Reserve space with min-height |
| Dynamic content | Use skeleton loaders |
| Animations | Use 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
| Resource | Optimization |
|---|---|
| Images | WebP/AVIF, lazy load below-fold, preload hero |
| Fonts | Preload, font-display: swap, subset |
| CSS | Inline critical CSS, defer non-critical |
| JavaScript | Defer/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
| Tool | Best For |
|---|---|
| 🔍 Google Lighthouse | Built into Chrome DevTools, comprehensive audits |
| 📊 PageSpeed Insights | Real-world data + lab data |
| 🧩 Web Vitals Extension | Real-time monitoring while browsing |
| ⚛️ React Profiler | Component-level performance analysis |
| 📦 Webpack Bundle Analyzer | Find what's bloating your bundle |
| 📈 Google Analytics | Track real user metrics |
Quick Lighthouse Check
- Open Chrome DevTools (F12)
- Go to Lighthouse tab
- Select "Performance"
- 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
| Issue | Metric | Fix |
|---|---|---|
| Elements jumping around | CLS | Add width/height, use aspect-ratio |
| Page feels empty | LCP | Preload hero, use WebP, inline critical CSS |
| Clicks feel slow | INP | Code split, useTransition, defer scripts |
| Everything is slow | TBT | Web 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
Found this article helpful?
Share it with your network and help others learn too!

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.
Related Articles
View all
The .vscode Folder: Your Team's Secret Weapon for Consistent Development
Every teammate using different VS Code settings? That means messy code, weird bugs, and a confused team. Learn how the .vscode folder can unify your development environment.

Organizing i18n Translation Files: Smart, Scalable, and Maintainable
Translation files starting as chaos? Learn how to structure your i18n files by feature, use clear naming conventions, leverage ICU syntax for plurals and dates, and keep your project maintainable.