
Core Web Vitals
- 17.6k installs
- 2.5k repo stars
- Updated June 14, 2026
- addyosmani/web-quality-skills
Core Web Vitals is an optimization skill covering the three Google-ranked page experience metrics (LCP, INP, CLS) with framework-agnostic patterns.
About
Core Web Vitals is a specialized skill for optimizing Largest Contentful Paint (LCP <2.5s), Interaction to Next Paint (INP <200ms), and Cumulative Layout Shift (CLS <0.1). Developers use it to improve page experience signals that directly affect Google Search ranking. Works across React, Vue, Nuxt, Astro, and plain HTML.
- Covers all three Core Web Vitals: LCP, INP, CLS with specific optimization targets
- Framework-agnostic patterns for React, Vue, Nuxt, Astro, and static HTML
- 150+ Lighthouse audit patterns across Performance, Accessibility, SEO, and Best Practices
Core Web Vitals by the numbers
- 17,619 all-time installs (skills.sh)
- +1,266 installs in the week ending Jul 28, 2026 (Skillselion tracking)
- Ranked #32 of 2,277 Frontend Development skills by installs in the Skillselion catalog
- Security screen: LOW risk (skills.sh audit)
- Data as of Jul 28, 2026 (Skillselion catalog sync)
core-web-vitals capabilities & compatibility
- Capabilities
- performance audit · seo optimization · accessibility check
- Use cases
- frontend · seo
npx skills add https://github.com/addyosmani/web-quality-skills --skill core-web-vitalsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 17.6k |
|---|---|
| repo stars | ★ 2.5k |
| Security audit | 3 / 3 scanners passed |
| Last updated | June 14, 2026 |
| Repository | addyosmani/web-quality-skills ↗ |
What it does
Core Web Vitals is a specialized skill for optimizing Largest Contentful Paint (LCP <2.5s), Interaction to Next Paint (INP <200ms), and Cumulative Layout Shift (CLS <0.1). Developers use it to improv
Who is it for?
Improving search ranking, reducing page load delays, minimizing layout shift
Skip if: Server infrastructure or database optimization
When should I use this skill?
Optimizing page experience, fixing Lighthouse performance scores, or addressing search ranking factors
What you get
Pages reach good thresholds (LCP <=2.5s, INP <=200ms, CLS <=0.1) and improve search ranking signals
- Optimized LCP loading strategy
- INP-friendly interaction patterns
- CLS-stable layout implementation
By the numbers
- Defines thresholds: LCP <=2.5s good, 2.5-4.0s needs improvement, >4.0s poor
- Covers 150+ Lighthouse audit patterns across four categories
- Provides performance budgets: <1.5MB total, <300KB JS (compressed), <100KB CSS
Files
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 rendering2. Render-blocking resources
<!-- ❌ Blocks rendering -->
<link rel="stylesheet" href="/all-styles.css">
<!-- ✅ Critical CSS inlined, rest deferred -->
<style>/* Critical above-fold CSS */</style>
<link rel="preload" href="/styles.css" as="style"
onload="this.onload=null;this.rel='stylesheet'">3. Slow resource load times
<!-- ❌ No hints, discovered late -->
<img src="/hero.jpg" alt="Hero">
<!-- ✅ Preloaded with high priority -->
<link rel="preload" href="/hero.webp" as="image" fetchpriority="high">
<img src="/hero.webp" alt="Hero" fetchpriority="high">4. Client-side rendering delays
// ❌ Content loads after JavaScript
useEffect(() => {
fetch('/api/hero-text').then(r => r.json()).then(setHeroText);
}, []);
// ✅ Server-side or static rendering
// Use SSR, SSG, or streaming to send HTML with content
export async function getServerSideProps() {
const heroText = await fetchHeroText();
return { props: { heroText } };
}5. Make navigations instant with the Speculation Rules API
For most sites, the LCP a user actually experiences is dominated by the next page they navigate to, not the one they landed on. Telling the browser to prerender likely-next pages on hover collapses that LCP to ~0ms.
<script type="speculationrules">
{
"prerender": [{
"where": { "href_matches": "/*" },
"eagerness": "moderate"
}]
}
</script>eagerness settings (cheapest → most aggressive): conservative (start on pointerdown), moderate (start after ~200ms hover), eager (start as soon as the link is in the viewport), immediate (start on page load). Start with moderate — it captures most navigations without prerendering pages users never visit.
Caveats:
- Bandwidth/CPU cost. Each prerender is roughly a full page load. Scope
wherecarefully (href_matchespatterns, exclude logout/checkout) and avoidimmediateoutside small sites. - Side effects fire early. Analytics, ads, and any code that runs on load will fire when the prerender starts, not when the user navigates. Gate side effects on the `prerenderingchange` event or
document.prerendering. - Chromium-only. Safari and Firefox ignore the script — it's a progressive enhancement, never a regression.
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)
- [ ] Speculation Rules added for likely-next navigations (moderate eagerness)LCP element identification
// Find your LCP element
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
// ❌ Long synchronous task
function processLargeArray(items) {
items.forEach(item => expensiveOperation(item));
}
// ✅ Break into chunks and yield to the scheduler. scheduler.yield() is the
// recommended modern API — its continuation is queued at a boosted
// priority so the rest of your work resumes ahead of unrelated tasks,
// while still letting the browser handle pending input first.
async function processLargeArray(items) {
const CHUNK_SIZE = 100;
for (let i = 0; i < items.length; i += CHUNK_SIZE) {
items.slice(i, i + CHUNK_SIZE).forEach(expensiveOperation);
if ('scheduler' in window && 'yield' in scheduler) {
await scheduler.yield();
} else {
// Fallback for browsers without scheduler.yield (Safari, older Firefox).
// setTimeout(0) yields but loses priority — your continuation may run
// after unrelated tasks the browser picked up in between.
await new Promise(r => setTimeout(r, 0));
}
}
}2. Heavy event handlers
// ❌ All work in handler
button.addEventListener('click', () => {
// Heavy computation
const result = calculateComplexThing();
// DOM updates
updateUI(result);
// Analytics
trackEvent('click');
});
// ✅ Prioritize visual feedback, then yield before doing the heavy work
button.addEventListener('click', async () => {
// 1. Immediate visual feedback (cheap DOM update)
button.classList.add('loading');
// 2. Yield so the browser can paint the loading state before we block
if ('scheduler' in window && 'yield' in scheduler) {
await scheduler.yield();
}
// 3. Now do the heavy work — the user already saw the click register
const result = calculateComplexThing();
updateUI(result);
// 4. Lowest-priority work last, when the main thread is idle
if ('requestIdleCallback' in window) {
requestIdleCallback(() => trackEvent('click'));
} else {
setTimeout(() => trackEvent('click'), 0);
}
});3. Third-party scripts
// ❌ Eagerly loaded, blocks interactions
<script src="https://heavy-widget.com/widget.js"></script>
// ✅ Lazy loaded on interaction or visibility
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)
// ❌ Re-renders entire tree
function App() {
const [count, setCount] = useState(0);
return (
<div>
<Counter count={count} />
<ExpensiveComponent /> {/* Re-renders on every count change */}
</div>
);
}
// ✅ Memoized expensive components
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 operationsINP debugging
// Identify slow interactions. durationThreshold: 40 matches what the
// web-vitals library uses — 16 (one frame) fires on nearly every interaction
// and drowns the console; 40 surfaces interactions that are starting to feel
// sluggish without spamming.
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: 40 });For field debugging across real users, prefer the web-vitals/attribution build of the web-vitals library — onINP() from that build attaches a LoAF (Long Animation Frame) breakdown identifying the longest script and the input/processing/presentation phase that ate the budget.
---
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
<!-- ❌ Causes layout shift when loaded -->
<img src="photo.jpg" alt="Photo">
<!-- ✅ Space reserved -->
<img src="photo.jpg" alt="Photo" width="800" height="600">
<!-- ✅ Or use aspect-ratio -->
<img src="photo.jpg" alt="Photo" style="aspect-ratio: 4/3; width: 100%;">2. Ads, embeds, and iframes
<!-- ❌ Unknown size until loaded -->
<iframe src="https://ad-network.com/ad"></iframe>
<!-- ✅ Reserve space with min-height -->
<div style="min-height: 250px;">
<iframe src="https://ad-network.com/ad" height="250"></iframe>
</div>
<!-- ✅ Or use aspect-ratio container -->
<div style="aspect-ratio: 16/9;">
<iframe src="https://youtube.com/embed/..."
style="width: 100%; height: 100%;"></iframe>
</div>3. Dynamically injected content
// ❌ Inserts content above viewport
notifications.prepend(newNotification);
// ✅ Insert below viewport or use transform
const insertBelow = viewport.bottom < newNotification.top;
if (insertBelow) {
notifications.prepend(newNotification);
} else {
// Animate in without shifting
newNotification.style.transform = 'translateY(-100%)';
notifications.prepend(newNotification);
requestAnimationFrame(() => {
newNotification.style.transform = '';
});
}4. Web fonts causing FOUT
/* ❌ Font swap shifts text */
@font-face {
font-family: 'Custom';
src: url('custom.woff2') format('woff2');
}
/* ✅ Optional font (no shift if slow) */
@font-face {
font-family: 'Custom';
src: url('custom.woff2') format('woff2');
font-display: optional;
}
/* ✅ Or match fallback metrics */
@font-face {
font-family: 'Custom';
src: url('custom.woff2') format('woff2');
font-display: swap;
size-adjust: 105%; /* Match fallback size */
ascent-override: 95%;
descent-override: 20%;
}5. Animations triggering layout
/* ❌ Animates layout properties */
.animate {
transition: height 0.3s, width 0.3s;
}
/* ✅ Use transform instead */
.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 contentCLS debugging
// Track layout shifts
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
// LCP: Use next/image with priority
import Image from 'next/image';
<Image src="/hero.jpg" priority fill alt="Hero" />
// INP: Use dynamic imports
const HeavyComponent = dynamic(() => import('./Heavy'), { ssr: false });
// CLS: Image component handles dimensions automaticallyReact
// LCP: Preload in head
<link rel="preload" href="/hero.jpg" as="image" fetchpriority="high" />
// INP: Memoize and useTransition
const [isPending, startTransition] = useTransition();
startTransition(() => setExpensiveState(newValue));
// CLS: Always specify dimensions in img tagsVue/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
- web.dev LCP
- web.dev INP
- web.dev CLS
- Performance skill
LCP optimization reference
What is LCP?
Largest Contentful Paint (LCP) measures when the largest content element in the viewport becomes visible. This is typically:
- An
<img>element - An
<image>element inside<svg> - A
<video>element with poster image - An element with a background image via
url() - A block-level element containing text nodes
LCP timeline
[ Server Response ][ Resource Load ][ Render ]
TTFB Download Paint
└─────────────────────────────────────┘
LCP TimeDetailed optimizations
1. Server response time (TTFB)
Target: < 800ms
Causes:
- Slow server/database queries
- No CDN/edge caching
- Inefficient backend code
- Cold starts (serverless)
Solutions:
// Use edge functions for dynamic content
// Vercel example
export const config = { runtime: 'edge' };
// Use stale-while-revalidate caching
// Cache-Control header
res.setHeader('Cache-Control', 's-maxage=60, stale-while-revalidate=300');2. Resource load time
For images:
<!-- Preload LCP image -->
<link rel="preload" as="image" href="/hero.webp"
imagesrcset="/hero-400.webp 400w, /hero-800.webp 800w"
imagesizes="100vw"
fetchpriority="high">
<!-- Modern format with fallback -->
<picture>
<source srcset="/hero.avif" type="image/avif">
<source srcset="/hero.webp" type="image/webp">
<img src="/hero.jpg" width="1200" height="600"
fetchpriority="high" alt="Hero">
</picture>For text (web fonts):
@font-face {
font-family: 'Heading';
src: url('/fonts/heading.woff2') format('woff2');
font-display: swap; /* Show fallback immediately */
}3. Render blocking resources
Critical CSS pattern:
<head>
<!-- Inline critical CSS -->
<style>
/* Only above-fold styles, < 14KB */
.hero { /* ... */ }
.nav { /* ... */ }
</style>
<!-- Defer non-critical CSS -->
<link rel="preload" href="/styles.css" as="style"
onload="this.onload=null;this.rel='stylesheet'">
</head>Defer JavaScript:
<!-- ❌ Blocks parsing -->
<script src="/app.js"></script>
<!-- ✅ Deferred (runs after HTML parsed) -->
<script defer src="/app.js"></script>
<!-- ✅ Module (deferred by default) -->
<script type="module" src="/app.mjs"></script>4. Client-side rendering
Problem: Content not in initial HTML.
Solutions:
Server-side rendering (SSR):
// Next.js
export async function getServerSideProps() {
const data = await fetchHeroContent();
return { props: { hero: data } };
}Static site generation (SSG):
// Next.js
export async function getStaticProps() {
const data = await fetchHeroContent();
return { props: { hero: data }, revalidate: 3600 };
}Streaming SSR:
// React 18+
import { Suspense } from 'react';
function Page() {
return (
<Suspense fallback={<HeroSkeleton />}>
<Hero />
</Suspense>
);
}Framework-specific tips
Next.js
import Image from 'next/image';
// LCP image with priority
<Image
src="/hero.jpg"
priority
fill
sizes="100vw"
alt="Hero"
/>Nuxt
<NuxtImg
src="/hero.jpg"
preload
loading="eager"
sizes="100vw"
/>Astro
---
import { Image } from 'astro:assets';
import hero from '../assets/hero.jpg';
---
<Image
src={hero}
loading="eager"
decoding="sync"
alt="Hero"
/>Debugging LCP
// Identify LCP element
new PerformanceObserver((entryList) => {
const entries = entryList.getEntries();
const lastEntry = entries[entries.length - 1];
console.log('LCP:', {
element: lastEntry.element,
time: lastEntry.startTime,
size: lastEntry.size,
url: lastEntry.url,
renderTime: lastEntry.renderTime,
loadTime: lastEntry.loadTime
});
}).observe({ type: 'largest-contentful-paint', buffered: true });Common issues
| Issue | Impact | Fix |
|---|---|---|
| No preload for LCP image | +500-1000ms | Add <link rel="preload"> |
| Large unoptimized image | +300-800ms | Compress, use WebP/AVIF |
| Render-blocking CSS | +200-500ms | Inline critical CSS |
| Slow TTFB | +300-2000ms | CDN, edge caching |
| Client-rendered content | +500-2000ms | SSR/SSG |
Related skills
How it compares
Use core-web-vitals for structured LCP playbooks when you already know scores are bad but need phase-by-phase fixes.
FAQ
What is LCP and how do I fix it?
Largest Contentful Paint measures when the largest element loads. Target <2.5s. Optimize critical rendering path, use next/image or nuxt/image, implement lazy loading, and preconnect to critical resources.
What framework patterns does this cover?
React/Next.js (next/image, React.lazy, useCallback/useMemo), Vue/Nuxt (nuxt/image, async components, computed), Svelte/SvelteKit, Astro (Image component, partial hydration), and static HTML (native lazy loading, picture element).
Is Core Web Vitals safe to install?
skills.sh reports 3 of 3 security scanners passed. Review the Security Audits panel on this page before installing in production.