| name | core-web-vitals |
| description | Optimize Core Web Vitals (LCP, INP, CLS) for better page experience and search ranking. Use when the task involves `improve Core Web Vitals`, `fix LCP`, `reduce CLS`, `optimize INP`, `page experience optimization`, or `fix layout shifts`. |
| license | MIT |
| metadata | {"version":"1.0.0"} |
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
<link rel="stylesheet" href="/all-styles.css">
<style></style>
<link rel="preload" href="/styles.css" as="style"
onload="this.onload=null;this.rel='stylesheet'">
3. Slow resource load times
<img src="/hero.jpg" alt="Hero">
<link rel="preload" href="/hero.webp" as="image" fetchpriority="high">
<img src="/hero.webp" alt="Hero" fetchpriority="high">
4. Client-side rendering delays
useEffect(() => {
fetch('/api/hero-text').then(r => r.json()).then(setHeroText);
}, []);
export async function getServerSideProps() {
const heroText = await fetchHeroText();
return { props: { heroText } };
}
LCP optimization checklist
- [ ] TTFB < 800ms (use CDN, edge caching)
- [ ] LCP image preloaded with fetchpriority="high"
- [ ] LCP image optimized (WebP/AVIF, correct size)
- [ ] Critical CSS inlined (< 14KB)
- [ ] No render-blocking JavaScript in <head>
- [ ] Fonts don't block text rendering (font-display: swap)
- [ ] LCP element in initial HTML (not JS-rendered)
LCP element identification
new PerformanceObserver((list) => {
const entries = list.getEntries();
const lastEntry = entries[entries.length - 1];
console.log('LCP element:', lastEntry.element);
console.log('LCP time:', lastEntry.startTime);
}).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
function processLargeArray(items) {
items.forEach(item => expensiveOperation(item));
}
async function processLargeArray(items) {
const CHUNK_SIZE = 100;
for (let i = 0; i < items.length; i += CHUNK_SIZE) {
const chunk = items.slice(i, i + CHUNK_SIZE);
chunk.forEach(item => expensiveOperation(item));
await new Promise(r => setTimeout(r, 0));
}
}
2. Heavy event handlers
button.addEventListener('click', () => {
const result = calculateComplexThing();
updateUI(result);
trackEvent('click');
});
button.addEventListener('click', () => {
button.classList.add('loading');
requestAnimationFrame(() => {
const result = calculateComplexThing();
updateUI(result);
});
requestIdleCallback(() => trackEvent('click'));
});
3. Third-party scripts
<script src="https://heavy-widget.com/widget.js"></script>
const loadWidget = () => {
import('https://heavy-widget.com/widget.js')
.then(widget => widget.init());
};
button.addEventListener('click', loadWidget, { once: true });
4. Excessive re-renders (React/Vue)
function App() {
const [count, setCount] = useState(0);
return (
<div>
<Counter count={count} />
<ExpensiveComponent /> {/* Re-renders on every count change */}
</div>
);
}
const MemoizedExpensive = React.memo(ExpensiveComponent);
function App() {
const [count, setCount] = useState(0);
return (
<div>
<Counter count={count} />
<MemoizedExpensive />
</div>
);
}
INP optimization checklist
- [ ] No tasks > 50ms on main thread
- [ ] Event handlers complete quickly (< 100ms)
- [ ] Visual feedback provided immediately
- [ ] Heavy work deferred with requestIdleCallback
- [ ] Third-party scripts don't block interactions
- [ ] Debounced input handlers where appropriate
- [ ] Web Workers for CPU-intensive operations
INP debugging
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.duration > 200) {
console.warn('Slow interaction:', {
type: entry.name,
duration: entry.duration,
processingStart: entry.processingStart,
processingEnd: entry.processingEnd,
target: entry.target
});
}
}
}).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
<img src="photo.jpg" alt="Photo">
<img src="photo.jpg" alt="Photo" width="800" height="600">
<img src="photo.jpg" alt="Photo" style="aspect-ratio: 4/3; width: 100%;">
2. Ads, embeds, and iframes
<iframe src="https://ad-network.com/ad"></iframe>
<div style="min-height: 250px;">
<iframe src="https://ad-network.com/ad" height="250"></iframe>
</div>
<div style="aspect-ratio: 16/9;">
<iframe src="https://youtube.com/embed/..."
style="width: 100%; height: 100%;"></iframe>
</div>
3. Dynamically injected content
notifications.prepend(newNotification);
const insertBelow = viewport.bottom < newNotification.top;
if (insertBelow) {
notifications.prepend(newNotification);
} else {
newNotification.style.transform = 'translateY(-100%)';
notifications.prepend(newNotification);
requestAnimationFrame(() => {
newNotification.style.transform = '';
});
}
4. Web fonts causing FOUT
@font-face {
font-family: 'Custom';
src: url('custom.woff2') format('woff2');
}
@font-face {
font-family: 'Custom';
src: url('custom.woff2') format('woff2');
font-display: optional;
}
@font-face {
font-family: 'Custom';
src: url('custom.woff2') format('woff2');
font-display: swap;
size-adjust: 105%;
ascent-override: 95%;
descent-override: 20%;
}
5. Animations triggering layout
.animate {
transition: height 0.3s, width 0.3s;
}
.animate {
transition: transform 0.3s;
}
.animate.expanded {
transform: scale(1.2);
}
CLS optimization checklist
- [ ] All images have width/height or aspect-ratio
- [ ] All videos/embeds have reserved space
- [ ] Ads have min-height containers
- [ ] Fonts use font-display: optional or matched metrics
- [ ] Dynamic content inserted below viewport
- [ ] Animations use transform/opacity only
- [ ] No content injected above existing content
CLS debugging
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (!entry.hadRecentInput) {
console.log('Layout shift:', entry.value);
entry.sources?.forEach(source => {
console.log(' Shifted element:', source.node);
console.log(' Previous rect:', source.previousRect);
console.log(' Current rect:', source.currentRect);
});
}
}
}).observe({ type: 'layout-shift', buffered: true });
Measurement tools
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
import {onLCP, onINP, onCLS} from 'web-vitals';
function sendToAnalytics({name, value, rating}) {
gtag('event', name, {
event_category: 'Web Vitals',
value: Math.round(name === 'CLS' ? value * 1000 : value),
event_label: rating
});
}
onLCP(sendToAnalytics);
onINP(sendToAnalytics);
onCLS(sendToAnalytics);
Framework quick fixes
Next.js
import Image from 'next/image';
<Image src="/hero.jpg" priority fill alt="Hero" />
const HeavyComponent = dynamic(() => import('./Heavy'), { ssr: false });
React
<link rel="preload" href="/hero.jpg" as="image" fetchpriority="high" />
const [isPending, startTransition] = useTransition();
startTransition(() => setExpensiveState(newValue));
Vue/Nuxt
<!-- LCP: Use nuxt/image with preload -->
<NuxtImg src="/hero.jpg" preload loading="eager" />
<!-- INP: Use async components -->
<component :is="() => import('./Heavy.vue')" />
<!-- CLS: Use aspect-ratio CSS -->
<img :style="{ aspectRatio: '16/9' }" />
References