TL;DR: Core Web Vitals are Google’s three key performance metrics that measure real user experience. LCP (Largest Contentful Paint) measures loading speed, INP (Interaction to Next Paint) measures interactivity, and CLS (Cumulative Layout Shift) measures visual stability. Good scores improve SEO rankings and user satisfaction. Target: LCP under 2.5s, INP under 200ms, CLS under 0.1.
Why Core Web Vitals Matter More Than You Think
Your website loads in 5 seconds. That feels fast to you.
But Google says it’s slow. Your rankings drop. Bounce rate increases. Conversions decrease.
Here’s what happened: You tested on your high-speed office connection. Your users are on 4G mobile networks, older devices, and slow connections. What feels fast to you is painfully slow for them.
Core Web Vitals are Google’s attempt to measure what users actually experience. Not synthetic lab tests. Real user data from Chrome browsers worldwide.
These metrics became ranking factors in 2021. In 2024, Google replaced FID with the stricter INP metric. Sites that ignore these signals lose rankings to competitors who optimize for them.
But here’s the thing: Core Web Vitals aren’t just about SEO. They correlate directly with business metrics. Amazon found that every 100ms of latency cost them 1% in sales. Pinterest reduced perceived wait times by 40% and saw a 15% increase in SEO traffic and signups.
Good Core Web Vitals mean users stay longer, convert more, and come back.
Understanding the Three Metrics
LCP: Largest Contentful Paint
What it measures: Time until the largest visible content element loads.
This could be:
- Hero image
- Video thumbnail
- Large text block
- Background image with text
Target scores:
- Good: Under 2.5 seconds
- Needs improvement: 2.5 to 4.0 seconds
- Poor: Over 4.0 seconds
Google measures from when the user requests the URL. They use the 75th percentile, meaning 75% of your page loads should hit the target.
Why it matters: LCP represents when your main content becomes visible. If your hero image takes 6 seconds to load, users see a blank screen and leave.
INP: Interaction to Next Paint
What it measures: Time between user interaction and visual response.
Interactions include:
- Clicking buttons
- Tapping links
- Typing in forms
- Opening menus
Target scores:
- Good: Under 200 milliseconds
- Needs improvement: 200 to 500 milliseconds
- Poor: Over 500 milliseconds
INP replaced FID (First Input Delay) in March 2024. INP is stricter because it measures the entire interaction lifecycle, not just the delay.
Why it matters: Nothing feels worse than clicking a button and waiting. Users perceive sites with fast INP as responsive and professional. Slow INP feels broken.
CLS: Cumulative Layout Shift
What it measures: Visual stability as the page loads.
Layout shifts happen when:
- Images load without dimensions
- Ads inject above content
- Fonts load and change size
- Dynamic content pushes existing content
Target scores:
- Good: Under 0.1
- Needs improvement: 0.1 to 0.25
- Poor: Over 0.25
The score is calculated as: Impact Fraction × Distance Fraction. A large element moving a long distance creates a high CLS score.
Why it matters: Ever start reading an article, then an ad loads and pushes everything down? You lose your place and get frustrated. That’s CLS. It ruins user experience.
How to Measure Core Web Vitals
Real User Monitoring (RUM)
Google Search Console (Free)
- Shows real user data from your site
- Navigate to Experience > Core Web Vitals
- See which URLs fail each metric
- Data updates every 28 days
This is your primary measurement tool. It shows actual user experiences, not lab simulations.
Chrome User Experience Report (CrUX)
- Public dataset of real user metrics
- Access via PageSpeed Insights
- Shows 28-day rolling average
- Origin-level and URL-level data
web-vitals JavaScript Library
import {onCLS, onINP, onLCP} from 'web-vitals';
onCLS(console.log);
onINP(console.log);
onLCP(console.log);
This sends real user metrics to your analytics. You can track Core Web Vitals for every visitor.
Lab Testing Tools
PageSpeed Insights (Free)
- URL: https://pagespeed.web.dev/
- Shows both lab and field data
- Provides specific recommendations
- Tests mobile and desktop separately
Lighthouse (Free, built into Chrome DevTools)
- Press F12 > Lighthouse tab
- Run audit on current page
- Detailed performance breakdown
- Throttling simulates slow connections
WebPageTest (Free)
- URL: https://www.webpagetest.org/
- Test from multiple locations
- Filmstrip view shows loading progression
- Waterfall chart reveals bottlenecks
Lab tests are useful for debugging but don’t replace real user data. Your office Macbook Pro will always score better than a user’s 3-year-old Android phone.
Fixing LCP (Largest Contentful Paint)
Optimize Server Response Time
Your Time to First Byte (TTFB) should be under 600ms. This is foundational.
Use quality hosting:
- Shared hosting for $3/month won’t cut it
- Managed WordPress hosts (WP Engine, Kinsta, Cloudways)
- Cloud hosting with global presence (Vercel, Netlify, Cloudflare Pages)
Implement caching:
WordPress example with W3 Total Cache:
// wp-config.php
define('WP_CACHE', true);
Or use server-level caching with Redis:
// Enable Redis object cache
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
Use a CDN:
- Cloudflare (free tier available)
- Fastly
- AWS CloudFront
- BunnyCDN (budget-friendly)
CDNs serve static assets from servers near your users. A user in Tokyo gets files from Tokyo, not Texas.
Optimize Images (Biggest LCP Killer)
Use modern formats:
- WebP: 25-35% smaller than JPEG
- AVIF: 20-30% smaller than WebP (newer, less browser support)
Convert images:
# Using ImageMagick
convert image.jpg -quality 85 image.webp
# Using cwebp
cwebp -q 85 image.jpg -o image.webp
Use proper sizing:
Serve different sizes for different screens:
<img
srcset="
hero-400.webp 400w,
hero-800.webp 800w,
hero-1200.webp 1200w
"
sizes="(max-width: 600px) 400px,
(max-width: 1200px) 800px,
1200px"
src="hero-800.webp"
alt="Hero image"
width="1200"
height="600"
>
The browser downloads only the size it needs.
Preload LCP image:
Tell the browser to load your hero image immediately:
<link rel="preload" as="image" href="hero.webp" fetchpriority="high">
This is one of the highest-impact optimizations. Preloading your LCP image can reduce LCP by 1-2 seconds.
Always include dimensions:
<img src="hero.webp" width="1200" height="600" alt="Hero">
This prevents layout shifts and helps the browser allocate space before the image loads.
Eliminate Render-Blocking Resources
Inline critical CSS:
Extract above-the-fold CSS and inline it in the <head>:
<style>
/* Critical CSS here - only styles for above-fold content */
.hero { background: #000; color: #fff; }
.nav { display: flex; }
</style>
Load remaining CSS asynchronously:
<link rel="preload" href="styles.css" as="style" onload="this.onload=null;this.rel='stylesheet'">
<noscript><link rel="stylesheet" href="styles.css"></noscript>
Tools to extract critical CSS:
- Critical (Node.js package)
- Penthouse
- Online tools: https://jonassebastianohlsson.com/criticalpathcssgenerator/
Defer non-critical JavaScript:
<script src="analytics.js" defer></script>
<script src="chat-widget.js" defer></script>
Or load asynchronously:
<script src="non-critical.js" async></script>
Difference:
defer: Downloads in parallel, executes after HTML parsingasync: Downloads in parallel, executes immediately when ready
Use defer for scripts that depend on DOM. Use async for independent scripts like analytics.
Optimize Web Fonts
Fonts can delay LCP by 1-3 seconds.
Use font-display:
@font-face {
font-family: 'CustomFont';
src: url('font.woff2') format('woff2');
font-display: swap;
}
Font-display options:
swap: Show fallback font immediately, swap when custom font loadsoptional: Use custom font if it loads fast, otherwise use fallbackblock: Wait for custom font (avoid this)
Preload fonts:
<link rel="preload" href="font.woff2" as="font" type="font/woff2" crossorigin>
Limit font weights:
Don’t load 9 font weights if you only use 2:
<!-- Bad: Loading everything -->
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@100;200;300;400;500;600;700;800;900" rel="stylesheet">
<!-- Good: Loading only what you need -->
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700" rel="stylesheet">
Fixing INP (Interaction to Next Paint)
Reduce JavaScript Execution Time
Break up long tasks:
JavaScript blocks the main thread. Tasks over 50ms are “long tasks” that delay interactions.
Bad approach:
// Long task: processes 10,000 items at once
function processItems(items) {
items.forEach(item => {
// Heavy processing
calculateComplexMetrics(item);
});
}
Better approach:
// Break into smaller chunks
async function processItems(items) {
for (let i = 0; i < items.length; i += 100) {
const chunk = items.slice(i, i + 100);
chunk.forEach(item => {
calculateComplexMetrics(item);
});
// Yield to main thread
await new Promise(resolve => setTimeout(resolve, 0));
}
}
This gives the browser time to respond to user interactions between chunks.
Use Web Workers for heavy computation:
// main.js
const worker = new Worker('worker.js');
worker.postMessage({data: largeDataset});
worker.onmessage = (e) => {
console.log('Result:', e.data);
};
// worker.js
self.onmessage = (e) => {
const result = performHeavyCalculation(e.data);
self.postMessage(result);
};
Web Workers run on separate threads. The main thread stays responsive.
Optimize Event Handlers
Debounce expensive operations:
// Bad: Runs on every keystroke
input.addEventListener('input', (e) => {
fetchSearchResults(e.target.value);
});
// Good: Waits 300ms after user stops typing
input.addEventListener('input', debounce((e) => {
fetchSearchResults(e.target.value);
}, 300));
function debounce(func, wait) {
let timeout;
return function(...args) {
clearTimeout(timeout);
timeout = setTimeout(() => func.apply(this, args), wait);
};
}
Use passive event listeners:
// Tells browser this listener won't call preventDefault()
// Allows scroll to start immediately
element.addEventListener('touchstart', handler, {passive: true});
Minimize Layout Thrashing
Batch DOM reads and writes:
Bad (causes multiple layouts):
// Read-write-read-write pattern forces layout recalculation
const height1 = el1.offsetHeight; // Read (layout)
el1.style.height = height1 * 2 + 'px'; // Write
const height2 = el2.offsetHeight; // Read (layout again)
el2.style.height = height2 * 2 + 'px'; // Write
Good (batch reads, then writes):
// Batch all reads first
const height1 = el1.offsetHeight;
const height2 = el2.offsetHeight;
// Then batch all writes
el1.style.height = height1 * 2 + 'px';
el2.style.height = height2 * 2 + 'px';
Use tools like FastDOM to automatically batch:
import fastdom from 'fastdom';
fastdom.measure(() => {
const height = element.offsetHeight;
fastdom.mutate(() => {
element.style.height = height * 2 + 'px';
});
});
Fixing CLS (Cumulative Layout Shift)
Always Set Image and Video Dimensions
Include width and height attributes:
<!-- This prevents layout shift -->
<img src="product.jpg" width="800" height="600" alt="Product">
The browser allocates space before the image loads.
For responsive images, use aspect-ratio:
img {
width: 100%;
height: auto;
aspect-ratio: 16 / 9;
}
Modern browsers maintain aspect ratio even before image loads.
Reserve Space for Ads and Embeds
Set minimum heights:
.ad-container {
min-height: 250px;
background: #f0f0f0;
}
For dynamic content, use skeleton screens:
<div class="skeleton-loader" style="height: 300px;">
<!-- Placeholder while content loads -->
</div>
Better user experience than blank space, and prevents shifts.
Avoid Inserting Content Above Existing Content
Load new content below existing:
Bad:
// Pushes existing content down
container.insertAdjacentHTML('afterbegin', newContent);
Good:
// Adds below existing content
container.insertAdjacentHTML('beforeend', newContent);
Use sticky positioning for notifications:
.notification {
position: sticky;
top: 0;
/* Doesn't push content */
}
Handle Web Fonts Properly
Match fallback font metrics:
Use font-display: swap with similar fallback:
@font-face {
font-family: 'CustomFont';
src: url('custom.woff2') format('woff2');
font-display: swap;
/* Adjust fallback to match custom font metrics */
size-adjust: 95%;
ascent-override: 90%;
descent-override: 20%;
}
body {
font-family: 'CustomFont', Arial, sans-serif;
}
Tools like Fontaine can calculate these values automatically.
Optimize Animations
Use transform and opacity only:
These properties don’t trigger layout:
/* Good - GPU accelerated */
.element {
transform: translateY(100px);
opacity: 0.5;
}
/* Bad - triggers layout */
.element {
top: 100px;
height: 200px;
}
Animate on composited layers:
.animated {
will-change: transform;
/* Or */
transform: translateZ(0);
}
This tells the browser to optimize for animation.
Common Mistakes That Kill Core Web Vitals
Using Too Many Third-Party Scripts
Every third-party script adds:
- Additional DNS lookups
- Network requests
- JavaScript execution
- Potential render blocking
Audit your third-party scripts:
// Check all third-party requests
performance.getEntriesByType('resource')
.filter(r => !r.name.includes(location.hostname))
.forEach(r => console.log(r.name, r.duration));
Solutions:
- Self-host critical scripts (Google Fonts, analytics)
- Load non-critical scripts with
asyncordefer - Use facades for heavy embeds (YouTube, maps)
Not Testing on Real Devices
Your Macbook Pro scores 100 on Lighthouse. Great.
Your users on $200 Android phones see 4-second load times.
Test on real devices:
- Use Chrome DevTools throttling (Slow 4G profile)
- Test on actual mid-range devices
- Check Search Console field data (real users)
Don’t optimize for lab scores. Optimize for real users.
Ignoring Mobile Performance
60% of web traffic is mobile. Mobile scores matter more.
Mobile devices have:
- Slower processors
- Less memory
- Slower connections
- Smaller screens (different LCP element)
Always check mobile scores separately. A site can score 95 on desktop and 35 on mobile.
Optimizing for Lighthouse Instead of Users
Lighthouse is a simulation. It doesn’t reflect real user diversity.
Focus on:
- Search Console Core Web Vitals report (real users)
- CrUX data (field metrics)
- Your own RUM implementation
Use Lighthouse for debugging, not as your primary metric.
Setting Performance Budgets
Define acceptable thresholds:
- LCP: Under 2.0s (stricter than Google’s 2.5s)
- INP: Under 150ms (stricter than 200ms)
- CLS: Under 0.05 (stricter than 0.1)
- Total page weight: Under 1.5MB
- JavaScript: Under 300KB
- Images: Under 800KB
Monitor regressions:
Use Lighthouse CI in your build process:
# .github/workflows/lighthouse.yml
name: Lighthouse CI
on: [push]
jobs:
lighthouse:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Run Lighthouse CI
run: |
npm install -g @lhci/cli
lhci autorun
This prevents performance regressions from being deployed.
Alert on failures:
Set up monitoring with:
- Google Search Console (free alerts)
- PageSpeed Insights API (automated checks)
- Commercial tools (SpeedCurve, Calibre, DebugBear)
Quick Wins: Immediate Improvements
Start here for fastest impact:
- Preload LCP image – Add one line of code, save 1-2 seconds
- Set image dimensions – Prevents CLS instantly
- Use a CDN – Cloudflare free tier takes 5 minutes
- Defer JavaScript – Add
deferattribute to scripts - Compress images – Use TinyPNG or ImageOptim
- Enable compression – Gzip or Brotli on server
- Implement caching – Browser and server-side
- Remove unused CSS/JS – Use PurgeCSS or UnCSS
These require minimal technical knowledge but deliver significant improvements.
FAQ
Q: Do Core Web Vitals affect rankings equally for all sites?
No. Google uses them as a tiebreaker. If two sites have similar content quality, the one with better Core Web Vitals ranks higher. But great content with poor vitals beats poor content with great vitals.
Q: Should I optimize for lab scores or field data?
Field data (real users) is what matters for rankings. Use lab tools for debugging and testing fixes, but measure success with Search Console field data.
Q: How long until improvements show in Search Console?
Search Console updates every 28 days using a rolling average. After making improvements, wait 4-6 weeks to see full impact.
Q: Can third-party scripts ruin my Core Web Vitals?
Absolutely. A single slow third-party script can destroy LCP and INP. Audit all third-party resources and consider alternatives or facades.
Q: What’s the difference between FID and INP?
FID measured only the first interaction delay. INP measures responsiveness throughout the entire page lifecycle and is much stricter. Google replaced FID with INP in March 2024.
Q: Do Core Web Vitals matter for single-page applications (SPAs)?
Yes, but they’re measured differently. Soft navigations (route changes without full page load) are included in INP. CLS still matters during transitions.
Q: Should I prioritize LCP, INP, or CLS first?
Start with LCP. It’s usually the easiest to fix and has the most dramatic impact on user experience. Then INP, then CLS.
Further Reading
Official Resources:
- Google Web Vitals Documentation: https://web.dev/vitals/
- Chrome User Experience Report: https://developers.google.com/web/tools/chrome-user-experience-report
- Web Vitals JavaScript Library: https://github.com/GoogleChrome/web-vitals
Testing Tools:
- PageSpeed Insights: https://pagespeed.web.dev/
- WebPageTest: https://www.webpagetest.org/
- Lighthouse: Built into Chrome DevTools
Learning Resources:
- Harry Roberts: https://csswizardry.com/
- Web.dev Learn Performance: https://web.dev/learn/#performance
- Request Map Generator: https://requestmap.webperf.tools/