Core Web Vitals optimization
Targeted optimization for the three Core Web Vitals metrics that affect Google Search ranking and user experience.
The three metrics
| Metric | Measures | Good | Needs work | Poor |
|---|
| LCP | Loading | ≤ 2.5s | 2.5s – 4s | > 4s |
| INP | Interactivity | ≤ 200ms | 200ms – 500ms | > 500ms |
| CLS | Visual Stability | ≤ 0.1 | 0.1 – 0.25 | > 0.25 |
Google measures at the 75th percentile — 75% of page visits must meet "Good" thresholds.
LCP: Largest Contentful Paint
LCP measures when the largest visible content element renders. Usually this is:
- Hero image or video
- Large text block
- Background image
<svg> element
Common LCP issues
1. Slow server response (TTFB > 800ms)
Fix: CDN, caching, optimized backend, edge rendering
2. Render-blocking resources
html
1<!-- ❌ Blocks rendering -->
2<link rel="stylesheet" href="/all-styles.css">
3
4<!-- ✅ Critical CSS inlined, rest deferred -->
5<style>/* Critical above-fold CSS */</style>
6<link rel="preload" href="/styles.css" as="style"
7 onload="this.onload=null;this.rel='stylesheet'">
3. Slow resource load times
html
1<!-- ❌ No hints, discovered late -->
2<img src="/hero.jpg" alt="Hero">
3
4<!-- ✅ Preloaded with high priority -->
5<link rel="preload" href="/hero.webp" as="image" fetchpriority="high">
6<img src="/hero.webp" alt="Hero" fetchpriority="high">
4. Client-side rendering delays
javascript
1// ❌ Content loads after JavaScript
2useEffect(() => {
3 fetch('/api/hero-text').then(r => r.json()).then(setHeroText);
4}, []);
5
6// ✅ Server-side or static rendering
7// Use SSR, SSG, or streaming to send HTML with content
8export async function getServerSideProps() {
9 const heroText = await fetchHeroText();
10 return { props: { heroText } };
11}
LCP optimization checklist
markdown
1- [ ] TTFB < 800ms (use CDN, edge caching)
2- [ ] LCP image preloaded with fetchpriority="high"
3- [ ] LCP image optimized (WebP/AVIF, correct size)
4- [ ] Critical CSS inlined (< 14KB)
5- [ ] No render-blocking JavaScript in <head>
6- [ ] Fonts don't block text rendering (font-display: swap)
7- [ ] LCP element in initial HTML (not JS-rendered)
LCP element identification
javascript
1// Find your LCP element
2new PerformanceObserver((list) => {
3 const entries = list.getEntries();
4 const lastEntry = entries[entries.length - 1];
5 console.log('LCP element:', lastEntry.element);
6 console.log('LCP time:', lastEntry.startTime);
7}).observe({ type: 'largest-contentful-paint', buffered: true });
INP: Interaction to Next Paint
INP measures responsiveness across ALL interactions (clicks, taps, key presses) during a page visit. It reports the worst interaction (at 98th percentile for high-traffic pages).
INP breakdown
Total INP = Input Delay + Processing Time + Presentation Delay
| Phase | Target | Optimization |
|---|
| Input Delay | < 50ms | Reduce main thread blocking |
| Processing | < 100ms | Optimize event handlers |
| Presentation | < 50ms | Minimize rendering work |
Common INP issues
1. Long tasks blocking main thread
javascript
1// ❌ Long synchronous task
2function processLargeArray(items) {
3 items.forEach(item => expensiveOperation(item));
4}
5
6// ✅ Break into chunks with yielding
7async function processLargeArray(items) {
8 const CHUNK_SIZE = 100;
9 for (let i = 0; i < items.length; i += CHUNK_SIZE) {
10 const chunk = items.slice(i, i + CHUNK_SIZE);
11 chunk.forEach(item => expensiveOperation(item));
12
13 // Yield to main thread
14 await new Promise(r => setTimeout(r, 0));
15 // Or use scheduler.yield() when available
16 }
17}
2. Heavy event handlers
javascript
1// ❌ All work in handler
2button.addEventListener('click', () => {
3 // Heavy computation
4 const result = calculateComplexThing();
5 // DOM updates
6 updateUI(result);
7 // Analytics
8 trackEvent('click');
9});
10
11// ✅ Prioritize visual feedback
12button.addEventListener('click', () => {
13 // Immediate visual feedback
14 button.classList.add('loading');
15
16 // Defer non-critical work
17 requestAnimationFrame(() => {
18 const result = calculateComplexThing();
19 updateUI(result);
20 });
21
22 // Use requestIdleCallback for analytics
23 requestIdleCallback(() => trackEvent('click'));
24});
3. Third-party scripts
javascript
1// ❌ Eagerly loaded, blocks interactions
2<script src="https://heavy-widget.com/widget.js"></script>
3
4// ✅ Lazy loaded on interaction or visibility
5const loadWidget = () => {
6 import('https://heavy-widget.com/widget.js')
7 .then(widget => widget.init());
8};
9button.addEventListener('click', loadWidget, { once: true });
4. Excessive re-renders (React/Vue)
javascript
1// ❌ Re-renders entire tree
2function App() {
3 const [count, setCount] = useState(0);
4 return (
5 <div>
6 <Counter count={count} />
7 <ExpensiveComponent /> {/* Re-renders on every count change */}
8 </div>
9 );
10}
11
12// ✅ Memoized expensive components
13const MemoizedExpensive = React.memo(ExpensiveComponent);
14
15function App() {
16 const [count, setCount] = useState(0);
17 return (
18 <div>
19 <Counter count={count} />
20 <MemoizedExpensive />
21 </div>
22 );
23}
INP optimization checklist
markdown
1- [ ] No tasks > 50ms on main thread
2- [ ] Event handlers complete quickly (< 100ms)
3- [ ] Visual feedback provided immediately
4- [ ] Heavy work deferred with requestIdleCallback
5- [ ] Third-party scripts don't block interactions
6- [ ] Debounced input handlers where appropriate
7- [ ] Web Workers for CPU-intensive operations
INP debugging
javascript
1// Identify slow interactions
2new PerformanceObserver((list) => {
3 for (const entry of list.getEntries()) {
4 if (entry.duration > 200) {
5 console.warn('Slow interaction:', {
6 type: entry.name,
7 duration: entry.duration,
8 processingStart: entry.processingStart,
9 processingEnd: entry.processingEnd,
10 target: entry.target
11 });
12 }
13 }
14}).observe({ type: 'event', buffered: true, durationThreshold: 16 });
CLS: Cumulative Layout Shift
CLS measures unexpected layout shifts. A shift occurs when a visible element changes position between frames without user interaction.
CLS Formula: impact fraction × distance fraction
Common CLS causes
1. Images without dimensions
html
1<!-- ❌ Causes layout shift when loaded -->
2<img src="photo.jpg" alt="Photo">
3
4<!-- ✅ Space reserved -->
5<img src="photo.jpg" alt="Photo" width="800" height="600">
6
7<!-- ✅ Or use aspect-ratio -->
8<img src="photo.jpg" alt="Photo" style="aspect-ratio: 4/3; width: 100%;">
2. Ads, embeds, and iframes
html
1<!-- ❌ Unknown size until loaded -->
2<iframe src="https://ad-network.com/ad"></iframe>
3
4<!-- ✅ Reserve space with min-height -->
5<div style="min-height: 250px;">
6 <iframe src="https://ad-network.com/ad" height="250"></iframe>
7</div>
8
9<!-- ✅ Or use aspect-ratio container -->
10<div style="aspect-ratio: 16/9;">
11 <iframe src="https://youtube.com/embed/..."
12 style="width: 100%; height: 100%;"></iframe>
13</div>
3. Dynamically injected content
javascript
1// ❌ Inserts content above viewport
2notifications.prepend(newNotification);
3
4// ✅ Insert below viewport or use transform
5const insertBelow = viewport.bottom < newNotification.top;
6if (insertBelow) {
7 notifications.prepend(newNotification);
8} else {
9 // Animate in without shifting
10 newNotification.style.transform = 'translateY(-100%)';
11 notifications.prepend(newNotification);
12 requestAnimationFrame(() => {
13 newNotification.style.transform = '';
14 });
15}
4. Web fonts causing FOUT
css
1/* ❌ Font swap shifts text */
2@font-face {
3 font-family: 'Custom';
4 src: url('custom.woff2') format('woff2');
5}
6
7/* ✅ Optional font (no shift if slow) */
8@font-face {
9 font-family: 'Custom';
10 src: url('custom.woff2') format('woff2');
11 font-display: optional;
12}
13
14/* ✅ Or match fallback metrics */
15@font-face {
16 font-family: 'Custom';
17 src: url('custom.woff2') format('woff2');
18 font-display: swap;
19 size-adjust: 105%; /* Match fallback size */
20 ascent-override: 95%;
21 descent-override: 20%;
22}
5. Animations triggering layout
css
1/* ❌ Animates layout properties */
2.animate {
3 transition: height 0.3s, width 0.3s;
4}
5
6/* ✅ Use transform instead */
7.animate {
8 transition: transform 0.3s;
9}
10.animate.expanded {
11 transform: scale(1.2);
12}
CLS optimization checklist
markdown
1- [ ] All images have width/height or aspect-ratio
2- [ ] All videos/embeds have reserved space
3- [ ] Ads have min-height containers
4- [ ] Fonts use font-display: optional or matched metrics
5- [ ] Dynamic content inserted below viewport
6- [ ] Animations use transform/opacity only
7- [ ] No content injected above existing content
CLS debugging
javascript
1// Track layout shifts
2new PerformanceObserver((list) => {
3 for (const entry of list.getEntries()) {
4 if (!entry.hadRecentInput) {
5 console.log('Layout shift:', entry.value);
6 entry.sources?.forEach(source => {
7 console.log(' Shifted element:', source.node);
8 console.log(' Previous rect:', source.previousRect);
9 console.log(' Current rect:', source.currentRect);
10 });
11 }
12 }
13}).observe({ type: 'layout-shift', buffered: true });
Lab testing
- Chrome DevTools → Performance panel, Lighthouse
- WebPageTest → Detailed waterfall, filmstrip
- Lighthouse CLI →
npx lighthouse <url>
Field data (real users)
- Chrome User Experience Report (CrUX) → BigQuery or API
- Search Console → Core Web Vitals report
- web-vitals library → Send to your analytics
javascript
1import {onLCP, onINP, onCLS} from 'web-vitals';
2
3function sendToAnalytics({name, value, rating}) {
4 gtag('event', name, {
5 event_category: 'Web Vitals',
6 value: Math.round(name === 'CLS' ? value * 1000 : value),
7 event_label: rating
8 });
9}
10
11onLCP(sendToAnalytics);
12onINP(sendToAnalytics);
13onCLS(sendToAnalytics);
Framework quick fixes
Next.js
jsx
1// LCP: Use next/image with priority
2import Image from 'next/image';
3<Image src="/hero.jpg" priority fill alt="Hero" />
4
5// INP: Use dynamic imports
6const HeavyComponent = dynamic(() => import('./Heavy'), { ssr: false });
7
8// CLS: Image component handles dimensions automatically
React
jsx
1// LCP: Preload in head
2<link rel="preload" href="/hero.jpg" as="image" fetchpriority="high" />
3
4// INP: Memoize and useTransition
5const [isPending, startTransition] = useTransition();
6startTransition(() => setExpensiveState(newValue));
7
8// CLS: Always specify dimensions in img tags
Vue/Nuxt
vue
1<!-- LCP: Use nuxt/image with preload -->
2<NuxtImg src="/hero.jpg" preload loading="eager" />
3
4<!-- INP: Use async components -->
5<component :is="() => import('./Heavy.vue')" />
6
7<!-- CLS: Use aspect-ratio CSS -->
8<img :style="{ aspectRatio: '16/9' }" />
References