core-web-vitals
Use this skill when optimizing Core Web Vitals - LCP (Largest Contentful Paint), INP (Interaction to Next Paint), and CLS (Cumulative Layout Shift). Triggers on page speed optimization, Lighthouse score improvement, fixing layout shifts, improving responsiveness, setting up performance monitoring with CrUX or RUM, and framework-specific CWV fixes for Next.js, Nuxt, Astro, and Remix.
marketing seocore-web-vitalslcpinpclsperformancelighthouseWhat is core-web-vitals?
Use this skill when optimizing Core Web Vitals - LCP (Largest Contentful Paint), INP (Interaction to Next Paint), and CLS (Cumulative Layout Shift). Triggers on page speed optimization, Lighthouse score improvement, fixing layout shifts, improving responsiveness, setting up performance monitoring with CrUX or RUM, and framework-specific CWV fixes for Next.js, Nuxt, Astro, and Remix.
core-web-vitals
core-web-vitals is a production-ready AI agent skill for claude-code, gemini-cli, openai-codex, and 1 more. Optimizing Core Web Vitals - LCP (Largest Contentful Paint), INP (Interaction to Next Paint), and CLS (Cumulative Layout Shift).
Quick Facts
| Field | Value |
|---|---|
| Category | marketing |
| Version | 0.1.0 |
| Platforms | claude-code, gemini-cli, openai-codex, mcp |
| License | MIT |
How to Install
- Make sure you have Node.js installed on your machine.
- Run the following command in your terminal:
npx skills add AbsolutelySkilled/AbsolutelySkilled --skill core-web-vitals- The core-web-vitals skill is now available in your AI coding agent (Claude Code, Gemini CLI, OpenAI Codex, etc.).
Overview
Core Web Vitals (CWV) are Google's user-centric page experience signals that directly affect Search ranking. They measure three dimensions of real-user experience: loading performance (LCP), interactivity (INP), and visual stability (CLS). Unlike synthetic benchmarks, CWV are evaluated on real user data collected via the Chrome User Experience Report (CrUX) at the 75th percentile - meaning 75% of your users must meet the threshold for a page to "pass". Poor CWV can suppress rankings regardless of content quality; good CWV is a ranking boost.
Tags
seo core-web-vitals lcp inp cls performance lighthouse
Platforms
- claude-code
- gemini-cli
- openai-codex
- mcp
Related Skills
Pair core-web-vitals with these complementary skills:
Frequently Asked Questions
What is core-web-vitals?
Use this skill when optimizing Core Web Vitals - LCP (Largest Contentful Paint), INP (Interaction to Next Paint), and CLS (Cumulative Layout Shift). Triggers on page speed optimization, Lighthouse score improvement, fixing layout shifts, improving responsiveness, setting up performance monitoring with CrUX or RUM, and framework-specific CWV fixes for Next.js, Nuxt, Astro, and Remix.
How do I install core-web-vitals?
Run npx skills add AbsolutelySkilled/AbsolutelySkilled --skill core-web-vitals in your terminal. The skill will be immediately available in your AI coding agent.
What AI agents support core-web-vitals?
This skill works with claude-code, gemini-cli, openai-codex, mcp. Install it once and use it across any supported AI coding agent.
Maintainers
Generated from AbsolutelySkilled
SKILL.md
Core Web Vitals
Core Web Vitals (CWV) are Google's user-centric page experience signals that directly affect Search ranking. They measure three dimensions of real-user experience: loading performance (LCP), interactivity (INP), and visual stability (CLS). Unlike synthetic benchmarks, CWV are evaluated on real user data collected via the Chrome User Experience Report (CrUX) at the 75th percentile - meaning 75% of your users must meet the threshold for a page to "pass". Poor CWV can suppress rankings regardless of content quality; good CWV is a ranking boost.
When to use this skill
Trigger this skill when the user:
- Asks why a page has poor Google Search ranking or Page Experience signals
- Wants to improve Lighthouse performance scores or pass Core Web Vitals assessment
- Reports layout shifts, janky interactions, or slow initial render
- Needs to diagnose which CWV metric is failing via CrUX or Lighthouse
- Wants to set up real user monitoring (RUM) for performance metrics
- Needs framework-specific CWV fixes (Next.js, Nuxt, Astro, Remix)
- Is configuring Lighthouse CI or performance budgets in a CI/CD pipeline
- Asks about fetchpriority, preload, scheduler.yield, or font-display
Do NOT trigger this skill for:
- General frontend performance work unrelated to CWV (e.g. reducing bundle size for DX, not UX)
- Backend-only optimizations with no user-facing impact (database query tuning, server caching)
Key principles
Field data (CrUX) trumps lab data (Lighthouse) for ranking - Lighthouse runs in a controlled lab environment. Google ranks pages on CrUX field data from real Chrome users. A perfect Lighthouse score does not guarantee a "Good" CrUX assessment. Always verify with Search Console's Core Web Vitals report or the CrUX API.
LCP < 2.5s, INP < 200ms, CLS < 0.1 are pass/fail gates - These are not targets to aim near; they are thresholds at the 75th percentile of real users. A page "passes" only when at least 75% of measured sessions hit "Good" for all three metrics simultaneously.
Fix the LCP element, not the whole page - LCP is always a single element (hero image, H1, video poster). Identify that element first using DevTools or Lighthouse. Optimizing the rest of the page won't move the metric if the LCP resource is still slow.
INP = Input Delay + Processing Time + Presentation Delay - Reducing INP requires understanding which phase is slow. A blocked main thread causes input delay; heavyweight event handlers cause processing time; forced style/layout causes presentation delay. Profile before optimizing.
CLS is about reserving space, not removing animations - Most CLS comes from unsized images, late-injected banners, or fonts causing reflow. Animations using CSS
transformandopacitydo not cause CLS. Fix the root cause (missing dimensions, no space reservation) rather than disabling motion.
Core concepts
The three metrics and their thresholds:
| Metric | What it measures | Good | Needs improvement | Poor |
|---|---|---|---|---|
| LCP | Time to render the largest visible content | < 2.5s | 2.5s - 4.0s | > 4.0s |
| INP | Worst interaction latency across the visit | < 200ms | 200ms - 500ms | > 500ms |
| CLS | Sum of unexpected layout shift scores | < 0.1 | 0.1 - 0.25 | > 0.25 |
How they're measured:
CWV come from two sources:
- Field data (CrUX): Real user measurements from Chrome browsers, aggregated over 28 days, reported at the 75th percentile. This is what Google uses for ranking. Available in Search Console, PageSpeed Insights, and the CrUX API.
- Lab data (Lighthouse / WebPageTest): Synthetic measurement from a controlled environment. Fast feedback loop during development, but does not directly affect rankings. Useful for catching regressions before shipping.
What elements trigger each metric:
- LCP candidates:
<img>,<image>inside SVG,<video>with a poster, block-level elements with a background image, block-level text nodes. The browser picks the largest by area in the viewport at paint time. - INP interactions: Any discrete interaction - click, tap, key press. Hover and scroll are excluded. INP reports the highest latency interaction (capped at 98th percentile for long visits).
- CLS triggers: Layout shifts where elements move unexpectedly without a user gesture. Shifts within 500ms of a user interaction (tap, scroll) are excluded from the score.
The 75th percentile rule:
A page "passes" CWV assessment only when 75% or more of its real-user sessions fall in the "Good" range for all three metrics. This means even if your median user has great performance, a slow tail of users (slow devices, poor networks) can fail the assessment. Optimize for the 75th percentile, not the average.
Common tasks
1. Diagnose which CWV metric is failing
Start with field data, not Lighthouse. Use the CrUX API to get real-user metrics per URL.
// CrUX API - get field data for a specific URL
const response = await fetch('https://chromeuxreport.googleapis.com/v1/records:queryRecord?key=YOUR_API_KEY', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
url: 'https://example.com/landing-page',
metrics: ['largest_contentful_paint', 'interaction_to_next_paint', 'cumulative_layout_shift']
})
});
const data = await response.json();
const { record } = data;
// Check 75th percentile values
const lcp = record.metrics.largest_contentful_paint.percentiles.p75; // ms
const inp = record.metrics.interaction_to_next_paint.percentiles.p75; // ms
const cls = record.metrics.cumulative_layout_shift.percentiles.p75; // score
console.log(`LCP p75: ${lcp}ms (${lcp < 2500 ? 'GOOD' : lcp < 4000 ? 'NI' : 'POOR'})`);
console.log(`INP p75: ${inp}ms (${inp < 200 ? 'GOOD' : inp < 500 ? 'NI' : 'POOR'})`);
console.log(`CLS p75: ${cls} (${cls < 0.1 ? 'GOOD' : cls < 0.25 ? 'NI' : 'POOR'})`);Load
references/lighthouse-ci.mdfor how to set up automated CWV monitoring.
2. Optimize LCP (hero image, preload, fetchpriority)
The fastest path to LCP improvement is ensuring the LCP resource is discovered and loaded early.
<!-- Step 1: Identify your LCP element, then preload it -->
<!-- Add this to <head> - discovered before the browser parses <body> -->
<link rel="preload" href="/hero.webp" as="image" fetchpriority="high">
<!-- Step 2: Mark the image with fetchpriority so the browser prioritizes it -->
<img
src="/hero.webp"
fetchpriority="high"
loading="eager"
width="1200"
height="630"
alt="Hero description"
/>
<!-- Step 3: Never use lazy loading on the LCP element -->
<!-- BAD: <img src="/hero.webp" loading="lazy"> -->For LCP elements that are CSS background images, use <link rel="preload"> with imagesrcset:
<link
rel="preload"
as="image"
href="/hero-800.webp"
imagesrcset="/hero-400.webp 400w, /hero-800.webp 800w, /hero-1600.webp 1600w"
imagesizes="(max-width: 600px) 100vw, 800px"
fetchpriority="high"
/>Load
references/lcp-optimization.mdfor TTFB optimization, critical CSS inlining, and LCP debugging in DevTools.
3. Fix CLS (image dimensions, font reservations, dynamic content)
CLS almost always comes from one of three sources: unsized media, web fonts reflow, or injected content.
<!-- Always set width + height on images - browser reserves space before load -->
<img src="product.webp" width="400" height="300" alt="Product photo" />
<!-- For responsive images, use aspect-ratio as fallback in CSS -->
<style>
img { aspect-ratio: attr(width) / attr(height); }
</style>/* Font CLS: use font-display: optional to avoid reflow entirely */
/* or font-display: swap + size-adjust for metrics matching */
@font-face {
font-family: 'Brand';
src: url('/fonts/brand.woff2') format('woff2');
font-display: optional; /* won't shift layout if font loads late */
}
/* Reserve space for ad slots, banners, or embeds */
.ad-slot {
min-height: 250px; /* known ad height */
contain: layout; /* isolate layout recalculations */
}Load
references/inp-cls-optimization.mdfor CLS session windows, Layout Shift Regions debugging, and font metrics matching.
4. Improve INP (break long tasks, scheduler.yield)
INP is dominated by main thread blocking. The primary fix is yielding back to the browser between heavy operations.
// Modern approach: scheduler.yield() (Chrome 115+)
async function handleClick(event) {
// Do immediate work first (within input delay budget)
updateButtonState(event.target);
// Yield before heavy processing - allows browser to paint
await scheduler.yield();
// Now do the expensive work
const result = await processLargeDataset();
renderResults(result);
}
// Fallback for browsers without scheduler.yield
function yieldToMain() {
return new Promise(resolve => setTimeout(resolve, 0));
}
// Break long synchronous loops
async function processItems(items) {
for (let i = 0; i < items.length; i++) {
processItem(items[i]);
// Yield every 50 items to stay under 50ms task budget
if (i % 50 === 0) await scheduler.yield?.() ?? await yieldToMain();
}
}Load
references/inp-cls-optimization.mdfor the three INP components, debouncing strategies, and Web Worker offloading.
5. Set up RUM with the web-vitals library
Capture real user CWV data and send it to your analytics endpoint.
import { onLCP, onINP, onCLS, onFCP, onTTFB } from 'web-vitals';
function sendToAnalytics({ name, value, rating, id, navigationType }) {
// Send to your analytics backend
fetch('/api/vitals', {
method: 'POST',
body: JSON.stringify({ name, value, rating, id, navigationType, url: location.href }),
headers: { 'Content-Type': 'application/json' }
});
}
// Register all metrics - use 'reportAllChanges: true' for INP to track intermediate values
onLCP(sendToAnalytics);
onINP(sendToAnalytics, { reportAllChanges: true });
onCLS(sendToAnalytics, { reportAllChanges: true });
onFCP(sendToAnalytics);
onTTFB(sendToAnalytics);The rating field is automatically set to 'good', 'needs-improvement', or 'poor' based on thresholds. Use it to segment your analytics dashboards.
6. Configure Lighthouse CI with performance budgets
Gate deployments on CWV regressions in CI.
# .github/workflows/lighthouse.yml
- name: Run Lighthouse CI
uses: treosh/lighthouse-ci-action@v11
with:
urls: |
https://staging.example.com/
https://staging.example.com/product/
budgetPath: ./lighthouse-budget.json
uploadArtifacts: true// lighthouse-budget.json
[{
"path": "/*",
"timings": [
{ "metric": "largest-contentful-paint", "budget": 2500 },
{ "metric": "total-blocking-time", "budget": 200 }
],
"resourceSizes": [
{ "resourceType": "script", "budget": 200 },
{ "resourceType": "image", "budget": 500 }
]
}]Load
references/lighthouse-ci.mdfor full LHCI setup, assertion configuration, and CrUX integration.
7. Framework-specific quick fixes
Each framework has first-party solutions that address CWV by default:
// Next.js: use next/image - handles sizing, lazy loading, and priority automatically
import Image from 'next/image';
// LCP image: add priority prop (sets fetchpriority="high" + preload)
<Image src="/hero.jpg" width={1200} height={630} priority alt="Hero" />
// Below-fold image: lazy loaded by default
<Image src="/product.jpg" width={400} height={400} alt="Product" /><!-- Nuxt: use <NuxtImg> from @nuxt/image module -->
<NuxtImg
src="/hero.jpg"
width="1200"
height="630"
preload
fetchpriority="high"
alt="Hero"
/><!-- Astro: use built-in <Image> component -->
import { Image } from 'astro:assets';
import heroImage from '../assets/hero.jpg';
<Image src={heroImage} width={1200} height={630} fetchpriority="high" alt="Hero" />Load
references/framework-cwv-fixes.mdfor complete per-framework patterns including font optimization, dynamic imports, and streaming.
Anti-patterns / common mistakes
| Mistake | Why it's wrong | What to do instead |
|---|---|---|
loading="lazy" on LCP image |
Delays discovery and load of the most critical resource | Use loading="eager" + fetchpriority="high" on LCP element |
No width/height on images |
Browser can't reserve space, causing layout shifts on load | Always set explicit dimensions; use aspect-ratio in CSS |
Blocking JS in <head> without defer |
Delays HTML parsing and LCP render | Add defer or async; move non-critical scripts to end of body |
| Client-side redirects for URL normalization | Adds a full round-trip before content loads | Use server-side 301/302 redirects; avoid JS location.href redirects |
Animating top/left/width/height |
Forces layout recalculation on every frame | Animate transform and opacity - compositor only, no layout cost |
| Injecting content above the fold after load | Pushes visible content down, creating massive CLS | Reserve space with min-height before content loads |
| Treating Lighthouse score as CrUX score | Lab score ≠ field score; Google ranks on field data | Verify with CrUX API or Search Console after optimization |
font-display: block for body fonts |
Invisible text for up to 3 seconds (FOIT) | Use font-display: swap for content fonts |
| Preloading non-LCP resources aggressively | Competes with LCP resource for bandwidth | Only preload the LCP resource and truly critical fonts |
| Ignoring mobile CrUX data | Desktop and mobile scores are reported separately | Check both; mobile is typically worse and weighted heavily |
Gotchas
Lighthouse score does not equal CrUX pass - A perfect Lighthouse 100 performance score can coexist with a "Poor" CrUX rating. Lighthouse runs on a fast simulated device; CrUX reflects real users on slow networks and low-end phones. Always verify with Search Console's Core Web Vitals report after deploying optimizations.
Preloading non-LCP resources causes regressions - Adding
<link rel="preload">to fonts, CSS, or non-LCP images competes with the actual LCP resource for bandwidth, often making LCP worse. Preload only the single LCP element and critical fonts withfont-display: optional.fetchpriority="high"on multiple images - Settingfetchpriority="high"on more than one image removes the browser's ability to prioritize. Only the single LCP element should carry this attribute; all below-fold images should useloading="lazy".INP spikes from third-party scripts - Third-party scripts (analytics, chat widgets, A/B testing tools) inject and execute on the main thread, directly increasing INP. These show up in performance profiles as long tasks triggered by user interactions. Lazy-load third-party scripts with
asyncordeferand consider loading them after the first user interaction.CLS from dynamically injected cookie banners - A cookie consent banner injected above the fold after initial render is one of the most common CLS sources. Reserve space for it with
min-heightbefore the JavaScript loads, or render it server-side so it is in the initial HTML.
References
For deep technical guidance on specific topics, load the relevant reference file:
references/lcp-optimization.md- TTFB, resource preloading, image optimization (AVIF/WebP, srcset), critical CSS, render-blocking elimination, DevTools debuggingreferences/inp-cls-optimization.md- INP three-component model, scheduler.yield, long tasks, CLS session windows, Layout Shift Regions, font metrics matchingreferences/framework-cwv-fixes.md- Next.js Image/font, Nuxt nuxt-img, Astro image integration, Remix prefetch and streamingreferences/lighthouse-ci.md- Lighthouse CI in GitHub Actions, performance budget schemas, CrUX API integration, RUM alerting
Only load a reference file when the current task requires that depth - they are detailed and will consume context.
References
framework-cwv-fixes.md
Framework-Specific CWV Fixes
Each major React/Vue/meta-framework ships first-party primitives that address Core Web Vitals at the framework level. Prefer these over manual implementations - they handle edge cases (responsive breakpoints, format negotiation, priority hints) that manual code often misses.
Next.js
next/image - LCP and CLS
next/image automatically handles: WebP/AVIF conversion, responsive srcset generation,
lazy loading below-fold images, aspect ratio reservation (prevents CLS), and placeholder
blur.
import Image from 'next/image';
// LCP hero image: add priority={true}
// - Adds fetchpriority="high" to the <img>
// - Generates a <link rel="preload"> in <head>
// - Disables lazy loading
export function HeroSection() {
return (
<Image
src="/hero.jpg"
width={1200}
height={630}
priority // critical: marks this as LCP candidate
alt="Hero image"
placeholder="blur" // optional: shows blurred LQIP while loading
blurDataURL="/hero-tiny.jpg" // or use static imports for auto blur
/>
);
}
// Below-fold product image: lazy loaded by default, no priority needed
export function ProductCard({ product }) {
return (
<Image
src={product.imageUrl}
width={400}
height={400}
alt={product.name}
// lazy loading + WebP conversion are automatic
/>
);
}Remote images require domain allowlist in next.config.js:
// next.config.js
module.exports = {
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'cdn.example.com',
pathname: '/images/**',
},
],
formats: ['image/avif', 'image/webp'], // serve AVIF first, then WebP
deviceSizes: [640, 750, 828, 1080, 1200, 1920], // srcset breakpoints
},
};next/font - CLS and LCP
next/font eliminates font-related CLS by: automatically calculating font metric overrides,
self-hosting Google Fonts (no external request), applying size-adjust so fallback and
web font have identical metrics.
// app/layout.js (or pages/_app.js)
import { Inter } from 'next/font/google';
const inter = Inter({
subsets: ['latin'],
display: 'swap', // or 'optional' for zero CLS
// Automatically calculates size-adjust, ascent-override, descent-override
// No font CLS even with display: 'swap'
});
export default function RootLayout({ children }) {
return (
<html lang="en" className={inter.className}>
<body>{children}</body>
</html>
);
}For local fonts:
import localFont from 'next/font/local';
const brandFont = localFont({
src: './fonts/Brand.woff2',
display: 'swap',
variable: '--font-brand', // CSS variable for use in Tailwind or CSS modules
});next/dynamic - INP and bundle size
Dynamic imports reduce initial JS, improving INP by reducing main thread parse/compile time.
import dynamic from 'next/dynamic';
// Lazy load heavy components not needed on initial render
const HeavyChart = dynamic(() => import('./HeavyChart'), {
loading: () => <div className="chart-skeleton" />, // prevents CLS
ssr: false, // skip server rendering for client-only components
});
// Conditional load: only load modal component when needed
const VideoPlayer = dynamic(() => import('./VideoPlayer'), {
ssr: false,
});
export function ProductPage() {
const [showVideo, setShowVideo] = useState(false);
return (
<>
<button onClick={() => setShowVideo(true)}>Watch Demo</button>
{showVideo && <VideoPlayer />}
</>
);
}Next.js Script component - third-party INP
Third-party scripts are a top INP killer. next/script provides loading strategies:
strategy="afterInteractive"- loads after hydration (analytics, tag managers)strategy="lazyOnload"- loads during browser idle time (chat widgets, social embeds)strategy="worker"- runs in a Web Worker via Partytown; no DOM access (experimental)
Nuxt
@nuxt/image - LCP and CLS
Install: npx nuxi@latest module add image
<!-- pages/index.vue -->
<template>
<!-- LCP hero: preload + fetchpriority via :preload and fetchpriority attrs -->
<NuxtImg
src="/hero.jpg"
width="1200"
height="630"
preload
fetchpriority="high"
format="webp"
quality="80"
alt="Hero"
/>
<!-- Responsive with srcset - provider handles format conversion -->
<NuxtPicture
src="/product.jpg"
:imgAttrs="{ width: 400, height: 400, alt: 'Product' }"
sizes="sm:100vw md:50vw lg:400px"
format="avif,webp"
/>
</template>// nuxt.config.ts
export default defineNuxtConfig({
modules: ['@nuxt/image', '@nuxt/fonts'],
image: {
quality: 80,
format: ['avif', 'webp'],
provider: 'cloudinary', // optional CDN provider
cloudinary: { baseURL: 'https://res.cloudinary.com/your-cloud/image/upload/' },
},
fonts: {
families: [
{ name: 'Inter', provider: 'google' },
{ name: 'Brand', src: '/fonts/brand.woff2' },
],
defaults: { weights: [400, 700], subsets: ['latin'] },
},
});Nuxt Fonts automatically self-hosts Google Fonts, adds font-face declarations with metric
overrides, and prevents CLS - equivalent to next/font behavior.
Nuxt lazy loading - INP
Use useLazyAsyncData to defer non-critical fetches. Prefix any component with Lazy in
templates to enable automatic code-split lazy loading (<LazyRecommendationsList />).
Reserve space for lazy sections with min-height to prevent CLS when content loads.
Astro
Astro's default architecture (zero client-side JS) produces excellent CWV scores out of the box. The key is knowing when and how to hydrate interactive islands.
astro:assets Image component - LCP and CLS
---
// src/pages/index.astro
import { Image, Picture } from 'astro:assets';
import heroImage from '../assets/hero.jpg'; // local image
---
<!-- LCP image: fetchpriority + eager loading -->
<Image
src={heroImage}
width={1200}
height={630}
fetchpriority="high"
loading="eager"
format="webp"
quality={80}
alt="Hero"
/>
<!-- Responsive with format fallbacks -->
<Picture
src={heroImage}
widths={[400, 800, 1200]}
sizes="(max-width: 600px) 100vw, (max-width: 1200px) 50vw, 1200px"
formats={['avif', 'webp']}
alt="Hero"
/>// astro.config.mjs
import { defineConfig } from 'astro/config';
export default defineConfig({
image: {
service: { entrypoint: 'astro/assets/services/sharp' },
remotePatterns: [{ hostname: '**.example.com' }],
},
});Astro island hydration - INP
Client directives control when components hydrate. Defer hydration of below-fold or non-interactive components to keep main thread free for INP.
---
import HeroCarousel from '../components/HeroCarousel.jsx';
import SocialFeed from '../components/SocialFeed.jsx';
import ChatWidget from '../components/ChatWidget.jsx';
import HeavyTable from '../components/HeavyTable.jsx';
---
<!-- client:load: hydrates immediately (use only for critical interactive UI) -->
<HeroCarousel client:load />
<!-- client:idle: hydrates during browser idle time (good for below-fold interactive) -->
<SocialFeed client:idle />
<!-- client:visible: hydrates when component enters viewport -->
<HeavyTable client:visible />
<!-- client:media: hydrates only on matching media query -->
<ChatWidget client:media="(min-width: 1024px)" />CWV impact of client directives:
client:load- contributes to initial JS bundle, may increase INPclient:idle- deferred, minimal INP impactclient:visible- best for below-fold heavy components- No directive (default) - static HTML, zero JS, best for CWV
For font CLS in Astro: self-host fonts in /public/fonts/, preload the woff2 file in the
<head>, and use font-display: optional in the @font-face declaration to eliminate
swap-related layout shifts. Use @astrojs/google-fonts for automatic self-hosting.
Remix
Image handling in Remix - LCP
Remix does not ship a built-in image component. Use @unpic/react or remix-image for
optimization, or manually manage srcset and fetchpriority.
// Manual LCP image with correct attributes
export default function HeroSection() {
return (
<img
src="/hero.webp"
srcSet="/hero-400.webp 400w, /hero-800.webp 800w, /hero-1200.webp 1200w"
sizes="(max-width: 600px) 100vw, 800px"
fetchPriority="high" // React camelCase
loading="eager"
width={1200}
height={630}
alt="Hero"
/>
);
}
// Add preload in the document head using Remix's links export
export const links = () => [
{
rel: 'preload',
as: 'image',
href: '/hero-800.webp',
imageSrcSet: '/hero-400.webp 400w, /hero-800.webp 800w, /hero-1200.webp 1200w',
imageSizes: '(max-width: 600px) 100vw, 800px',
},
];Remix prefetch - LCP on next page
Remix's <Link prefetch> pre-fetches the next page's data and assets on hover or when
visible, dramatically improving LCP for subsequent navigations.
import { Link } from '@remix-run/react';
// prefetch="intent": prefetch on hover (best for navigation links)
<Link to="/product/123" prefetch="intent">View Product</Link>
// prefetch="render": prefetch when link renders (use for critical CTAs)
<Link to="/checkout" prefetch="render">Checkout</Link>
// prefetch="viewport": prefetch when link is visible (use for list items)
<Link to={`/product/${id}`} prefetch="viewport">
{product.name}
</Link>Remix streaming with defer - INP and LCP
Streaming defers slow data without blocking the initial HTML response. The page shell (nav, above-fold content) arrives immediately, improving LCP for the critical path.
// routes/product.$id.jsx
import { defer } from '@remix-run/node';
import { Await, useLoaderData } from '@remix-run/react';
import { Suspense } from 'react';
export async function loader({ params }) {
// Critical data: awaited - included in initial HTML
const product = await getProduct(params.id);
// Non-critical data: deferred - streams in after initial paint
const recommendations = getRecommendations(params.id); // not awaited
return defer({ product, recommendations });
}
export default function ProductPage() {
const { product, recommendations } = useLoaderData();
return (
<>
{/* Renders immediately - LCP candidate */}
<ProductHero product={product} />
{/* Streams in - shows fallback until ready */}
<Suspense fallback={<RecommendationsSkeleton />}>
<Await resolve={recommendations}>
{(data) => <RecommendationsList items={data} />}
</Await>
</Suspense>
</>
);
}CWV impact of streaming:
- LCP improves because critical HTML arrives before slow data queries complete
- CLS risk: use
<Suspense>fallback with the same dimensions as the final content - INP unaffected - streaming is a network/render concern, not an interaction concern
inp-cls-optimization.md
INP and CLS Optimization Reference
INP - Interaction to Next Paint
INP replaced FID (First Input Delay) as a Core Web Vital in March 2024. It measures the worst interaction latency (at the 98th percentile for long visits) across the full page lifetime. Target: < 200ms at the 75th percentile of real users.
The Three INP Components
Every interaction (click, tap, key press) passes through three phases:
INP = Input Delay + Processing Time + Presentation Delay1. Input Delay (time from user gesture to event handler start)
Caused by: other tasks running on the main thread when the interaction fires. Long tasks (> 50ms) are the primary culprit - if a 200ms task is running when the user clicks, the event waits for it to finish.
2. Processing Time (time spent running event handlers)
Caused by: heavyweight synchronous code inside click/keydown handlers. Reading the DOM, running loops, synchronous XHR, or calling expensive library functions all add to this.
3. Presentation Delay (time from handler finish to next frame painted)
Caused by: forced synchronous layout (layout thrashing), large style recalculations, or heavy paint operations. Reading layout properties (offsetWidth, getBoundingClientRect) after writing to the DOM forces an early layout.
Diagnosing INP in DevTools
- Open Chrome DevTools > Performance panel
- Record while interacting with the page
- Look for long tasks (red triangles in the main thread row)
- Find the interaction in the "Interactions" track
- Click it to see input delay / processing time / presentation delay breakdown
Alternatively, use the INP Attribution API:
import { onINP } from 'web-vitals/attribution';
onINP(({ value, attribution }) => {
const { interactionTarget, inputDelay, processingDuration, presentationDelay } = attribution;
console.log({
inp: value,
element: interactionTarget, // CSS selector of the clicked element
inputDelay, // ms waiting for main thread
processingDuration, // ms running handlers
presentationDelay, // ms waiting for frame
phase: inputDelay > processingDuration
? 'INPUT_DELAY'
: processingDuration > presentationDelay
? 'PROCESSING'
: 'PRESENTATION'
});
}, { reportAllChanges: true });Fixing Input Delay: Break Long Tasks
The main thread must be free when users interact. Any task > 50ms is a "long task" that creates input delay. Break them up so the browser can process interactions between chunks.
// Modern: scheduler.yield() - Chrome 115+
// Yields control back to the browser, allowing input events to be processed
async function processLargeList(items) {
const results = [];
for (let i = 0; i < items.length; i++) {
results.push(processItem(items[i]));
// Yield every N items to stay under 50ms task budget
if (i % 20 === 0) {
await scheduler.yield();
}
}
return results;
}
// Polyfill / fallback for browsers without scheduler.yield
function yieldToMain() {
if ('scheduler' in globalThis && 'yield' in scheduler) {
return scheduler.yield();
}
return new Promise(resolve => setTimeout(resolve, 0));
}Fixing Processing Time: Lightweight Event Handlers
Event handlers must complete quickly. Defer heavy work that doesn't need to happen synchronously.
// BAD: Heavy synchronous processing in click handler
button.addEventListener('click', () => {
const data = processEntireDataset(largeArray); // 300ms of work
renderTable(data);
});
// GOOD: Update UI immediately, defer heavy work
button.addEventListener('click', async () => {
// Immediately update UI state (gives user feedback)
button.disabled = true;
button.textContent = 'Processing...';
// Yield before heavy work - this frame will paint the button state
await scheduler.yield();
// Now do expensive processing
const data = await processEntireDataset(largeArray);
renderTable(data);
button.textContent = 'Done';
});Fixing Presentation Delay: Avoid Layout Thrashing
Layout thrashing occurs when you interleave DOM reads and writes, forcing the browser to recalculate layout multiple times.
// BAD: Reads and writes interleaved - forces reflow on every iteration
elements.forEach(el => {
const height = el.offsetHeight; // read -> forces layout
el.style.height = (height * 1.5) + 'px'; // write
});
// GOOD: Batch all reads, then all writes
const heights = elements.map(el => el.offsetHeight); // all reads (one layout)
elements.forEach((el, i) => {
el.style.height = (heights[i] * 1.5) + 'px'; // all writes
});Properties that trigger layout when read: offsetWidth/Height, clientWidth/Height,
scrollTop/Left, getBoundingClientRect(), getComputedStyle().
Debouncing and Web Workers
For continuous events (input, resize), debounce with setTimeout (300ms for search) or
throttle for scroll handlers. Do not debounce click - it harms perceived responsiveness.
For CPU-heavy work that doesn't need the DOM (data parsing, encryption, image processing),
use new Worker('/worker.js') to move computation off the main thread entirely. Post data
with worker.postMessage(payload) and receive results via worker.onmessage.
CLS - Cumulative Layout Shift
CLS measures visual instability: the sum of all unexpected layout shift scores during a page's lifetime. Target: < 0.1 at the 75th percentile of real users.
How CLS Score is Calculated
Each layout shift has a score = impact fraction * distance fraction:
- Impact fraction: fraction of the viewport area affected by the shifting element
- Distance fraction: fraction of the viewport that the element moved
CLS aggregates these using session windows: groups of shifts where each shift is within 1 second of the previous, and the window is at most 5 seconds total. The worst session window score is the CLS value.
// Observe layout shift entries
let cls = 0;
let sessionWindowStart = 0;
let sessionWindowValue = 0;
let clsValue = 0;
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
// Ignore shifts from user interactions (within 500ms of input)
if (entry.hadRecentInput) continue;
const now = entry.startTime;
if (now - sessionWindowStart > 5000 || entry.startTime - sessionWindowStart > 1000) {
// New session window
sessionWindowStart = entry.startTime;
sessionWindowValue = 0;
}
sessionWindowValue += entry.value;
clsValue = Math.max(clsValue, sessionWindowValue);
}
}).observe({ type: 'layout-shift', buffered: true });Debugging with Layout Shift Regions
Enable Layout Shift Regions in Chrome DevTools to visually identify what's shifting:
- DevTools > More tools > Rendering
- Enable "Layout Shift Regions" (highlights shifting elements in blue/teal)
- Reload the page and watch for highlighted areas
Alternatively, use PerformanceObserver to log the shifting nodes:
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.hadRecentInput) continue;
// sources shows which elements shifted and by how much
for (const source of entry.sources) {
console.log({
element: source.node,
previousRect: source.previousRect,
currentRect: source.currentRect,
score: entry.value
});
}
}
}).observe({ type: 'layout-shift', buffered: true });Fix 1: Always Set Image Dimensions
The most common CLS source. Without dimensions, the browser doesn't know the image size until it downloads, causing everything below it to shift down.
<!-- Always provide width and height - browser reserves space immediately -->
<img src="/product.webp" width="400" height="300" alt="Product" />
<!-- For responsive images, also add CSS to allow scaling -->
<style>
img {
max-width: 100%;
height: auto; /* maintains aspect ratio when width changes */
}
</style>
<!-- CSS aspect-ratio works even without explicit dimensions -->
<style>
.hero-image {
width: 100%;
aspect-ratio: 16 / 9;
object-fit: cover;
}
</style>Fix 2: Reserve Space for Dynamic Content
Ads, embeds, and content loaded after the initial HTML shift existing content.
/* Ad slot: reserve the known ad size */
.ad-slot {
min-height: 250px; /* standard banner height */
width: 300px;
/* Or use aspect-ratio for responsive ads */
}
/* Embed placeholder: show skeleton while content loads */
.embed-container {
aspect-ratio: 16 / 9;
background: #f5f5f5;
contain: layout; /* isolates layout recalculations to this element */
}
/* Infinite scroll: prevent CLS when new items load above viewport */
.list-container {
overflow-anchor: none; /* disable scroll anchoring - manage manually if needed */
}Fix 3: Font Loading Without Layout Shift
Web fonts cause CLS when:
font-display: swapis used and the fallback metrics differ significantly from the web font- The web font causes text reflow (character widths change)
Option A: font-display: optional (zero CLS, may not use web font on slow connections)
@font-face {
font-family: 'Brand';
src: url('/fonts/brand.woff2') format('woff2');
font-display: optional; /* use fallback if font isn't cached; no swap, no CLS */
}Option B: font-display: swap + size-adjust for metrics matching
/* First, measure fallback vs web font metrics using fontpie or f-mods.netlify.app */
@font-face {
font-family: 'Brand-Fallback';
src: local('Arial');
/* Adjust fallback metrics to match Brand font exactly */
size-adjust: 107%;
ascent-override: 94%;
descent-override: 24%;
line-gap-override: 0%;
}
@font-face {
font-family: 'Brand';
src: url('/fonts/brand.woff2') format('woff2');
font-display: swap;
}
/* Use both: browser uses fallback with adjusted metrics, swaps to Brand (no visible shift) */
body {
font-family: 'Brand', 'Brand-Fallback', sans-serif;
}Tools for calculating font override values: fontpie (npm), next/font (automatic).
Fix 4: Animations That Don't Cause CLS
Animations using transform and opacity are compositor-only and do NOT cause layout
shifts. Animations of layout-triggering properties (top, left, width, height, margin,
padding) DO cause CLS.
/* CAUSES CLS: animating layout properties */
.bad-animation {
transition: top 300ms ease, height 300ms ease;
}
/* NO CLS: transform and opacity are compositor-only */
.good-animation {
transition: transform 300ms ease, opacity 300ms ease;
}
/* Slide-in from below using transform (no CLS) */
@keyframes slide-up {
from { transform: translateY(20px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
/* Expand/collapse using scale instead of height */
.expandable {
transform-origin: top;
transition: transform 300ms ease, opacity 300ms ease;
}
.expandable.collapsed {
transform: scaleY(0);
opacity: 0;
}Exception: Animations triggered by a user interaction (click, tap) within 500ms are excluded from CLS scoring. The browser assumes user-initiated layout changes are expected.
lcp-optimization.md
LCP Optimization Reference
LCP (Largest Contentful Paint) measures the time from navigation start to when the largest visible content element is rendered. Target: < 2.5s at the 75th percentile of real users.
LCP Breakdown: Four Sub-Parts
LCP time can be decomposed into four additive phases. Identify which phase is slowest before optimizing.
LCP = TTFB + Resource Load Delay + Resource Load Duration + Element Render Delay| Phase | What it measures | Optimization target |
|---|---|---|
| TTFB | Time to first byte from server | Server performance, CDN, caching |
| Resource Load Delay | Time from TTFB to when LCP resource starts loading | Preload, fetchpriority, no lazy-load |
| Resource Load Duration | Time to download the LCP resource | Image compression, CDN, HTTP/2 |
| Element Render Delay | Time from resource loaded to element painted | Remove render-blocking, critical CSS |
Use Chrome DevTools Performance panel or PageSpeed Insights "Opportunities" to identify which phase dominates.
Phase 1: TTFB Optimization
TTFB > 600ms is a red flag. The browser cannot start loading the LCP resource until the initial HTML arrives.
Server-side improvements
# Nginx: enable gzip and set cache headers for HTML
server {
gzip on;
gzip_types text/html text/css application/javascript;
location / {
# For dynamic pages: short TTL with stale-while-revalidate
add_header Cache-Control "public, max-age=0, stale-while-revalidate=60";
proxy_pass http://app_server;
}
location ~* \.(js|css|webp|avif|woff2)$ {
# Static assets: immutable cache (filename has hash)
add_header Cache-Control "public, max-age=31536000, immutable";
}
}CDN and edge caching
- Serve HTML from a CDN edge node close to the user when content can be cached
- For personalized pages, use CDN with Edge Side Includes (ESI) or edge functions to cache the shell
- Use
stale-while-revalidateto avoid cache misses impacting TTFB:
Cache-Control: public, max-age=60, stale-while-revalidate=600Preconnect to critical origins
<!-- In <head> - establishes TCP+TLS to critical third-party origins before they're needed -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://cdn.example.com" crossorigin>
<!-- dns-prefetch is lighter - use for non-critical third parties -->
<link rel="dns-prefetch" href="https://analytics.example.com">Phase 2: Resource Load Delay Elimination
The LCP resource must start loading as early as possible. Two common mistakes delay it:
Mistake 1: LCP image is not in initial HTML
When images are injected by JavaScript (carousel libraries, CMS hydration, lazy components), the browser cannot discover them during HTML parsing. The LCP resource starts loading seconds late.
Fix: Ensure the LCP <img> is in the server-rendered HTML, not injected by JS.
Mistake 2: Missing preload for background-image LCP
If the LCP element uses a CSS background-image, the browser won't discover it until the CSS is
parsed. Use <link rel="preload"> to fetch it early.
<!-- Preload CSS background LCP image -->
<link
rel="preload"
as="image"
href="/hero-800.webp"
imagesrcset="/hero-400.webp 400w, /hero-800.webp 800w, /hero-1600.webp 1600w"
imagesizes="(max-width: 600px) 100vw, 800px"
fetchpriority="high"
/>
<!-- Or for a simple non-responsive background image -->
<link rel="preload" as="image" href="/hero.webp" fetchpriority="high" />fetchpriority attribute
fetchpriority="high" instructs the browser to prioritize the LCP resource above other fetches.
Without it, the browser may deprioritize images during the initial burst of network requests.
<!-- On the LCP img element -->
<img
src="/hero.webp"
fetchpriority="high"
loading="eager"
width="1200"
height="630"
alt="Hero"
/>
<!-- Never use fetchpriority="high" on multiple images - defeats the purpose -->
<!-- Reserve it for exactly one element: the LCP candidate -->Phase 3: Image Optimization
A fast-discovered image that's 2MB will still be slow. Reduce the payload.
Modern image formats
| Format | Compression vs JPEG | Use case | Browser support |
|---|---|---|---|
| AVIF | 50% smaller | Photos, highest quality | ~92% (2024) |
| WebP | 25-35% smaller | Photos + graphics | ~97% |
| SVG | N/A | Icons, logos, illustrations | Universal |
| JPEG | baseline | Fallback for photos | Universal |
Use <picture> for format negotiation with graceful fallback:
<picture>
<source srcset="/hero.avif" type="image/avif">
<source srcset="/hero.webp" type="image/webp">
<img
src="/hero.jpg"
fetchpriority="high"
loading="eager"
width="1200"
height="630"
alt="Hero image"
/>
</picture>Responsive images with srcset + sizes
The browser uses sizes to calculate the display width, then picks the best srcset candidate.
An incorrectly sized image wastes bandwidth or delivers a pixelated result.
<img
srcset="
/hero-400.webp 400w,
/hero-800.webp 800w,
/hero-1200.webp 1200w,
/hero-1600.webp 1600w
"
sizes="
(max-width: 600px) 100vw,
(max-width: 1200px) 50vw,
800px
"
src="/hero-800.webp"
fetchpriority="high"
loading="eager"
width="1600"
height="900"
alt="Hero"
/>How sizes works: The browser picks the first matching media condition and uses that
display width to select the appropriate srcset entry. Always define a final non-media
fallback (e.g., 800px).
Image compression settings
| Format | Tool | Recommended quality |
|---|---|---|
| AVIF | sharp, squoosh, avifenc |
quality 60-75 |
| WebP | sharp, cwebp, squoosh |
quality 75-85 |
| JPEG | mozjpeg, imagemin-mozjpeg |
quality 75-85 |
// sharp (Node.js) - convert and resize for srcset
import sharp from 'sharp';
const widths = [400, 800, 1200, 1600];
for (const width of widths) {
await sharp('hero-original.jpg')
.resize(width)
.webp({ quality: 80 })
.toFile(`hero-${width}.webp`);
await sharp('hero-original.jpg')
.resize(width)
.avif({ quality: 65 })
.toFile(`hero-${width}.avif`);
}Image CDN integration
Services like Cloudinary, Imgix, and Cloudflare Images transform images on the fly via URL parameters, eliminating build-time generation:
<!-- Cloudinary: auto format + quality, resize to 800px wide -->
<img
src="https://res.cloudinary.com/demo/image/upload/f_auto,q_auto,w_800/hero.jpg"
fetchpriority="high"
width="800"
height="450"
alt="Hero"
/>Phase 4: Render-Blocking Elimination
Critical CSS inlining
Render-blocking stylesheets delay the first paint. Extract the minimum CSS needed to render above-the-fold content and inline it; load the rest asynchronously.
<head>
<!-- Inline critical CSS - no network round-trip needed -->
<style>
/* Minimal styles for hero, nav, and above-fold layout */
body { margin: 0; font-family: system-ui, sans-serif; }
.hero { width: 100%; aspect-ratio: 16 / 9; background: #f5f5f5; }
nav { display: flex; padding: 1rem; }
</style>
<!-- Load full stylesheet asynchronously - won't block render -->
<link rel="preload" href="/styles.css" as="style" onload="this.rel='stylesheet'">
<noscript><link rel="stylesheet" href="/styles.css"></noscript>
</head>Tools for generating critical CSS: critical (npm), critters (webpack plugin), Astro's built-in critical CSS.
Eliminate parser-blocking scripts
<!-- Parser-blocking: stops HTML parsing until script downloads + executes -->
<script src="/app.js"></script>
<!-- Deferred: HTML parses fully, then script runs in order -->
<script src="/app.js" defer></script>
<!-- Async: executes as soon as downloaded, may interrupt parsing -->
<!-- Use only for independent scripts like analytics -->
<script src="/analytics.js" async></script>
<!-- ES modules are deferred by default -->
<script type="module" src="/app.js"></script>Rule: Never place <script> without defer or async in <head> unless it's truly
critical for initial render (rare).
Third-party script impact on LCP
Third-party scripts (chat widgets, A/B testing, tag managers) are common LCP killers. They:
- Compete for main thread time
- Inject content that becomes the LCP element (e.g., hero banner from CMS)
- Block rendering if loaded synchronously
Audit with: Chrome DevTools > Performance > "Third-party usage" section or web.dev/third-party-summary.
<!-- Load third parties after LCP renders - use facade pattern -->
<!-- Delay chat widget initialization until after user interaction -->
<script>
// Don't load chat SDK on initial paint
document.addEventListener('click', loadChatWidget, { once: true });
document.addEventListener('scroll', loadChatWidget, { once: true });
function loadChatWidget() {
const script = document.createElement('script');
script.src = 'https://cdn.chat-provider.com/widget.js';
document.body.appendChild(script);
}
</script>Debugging LCP in Chrome DevTools
Finding the LCP element
- Open DevTools > Performance panel
- Record a page load
- Click the "LCP" marker in the timeline
- The "Related Node" in the summary shows the LCP element
Alternatively, use the console:
// Log LCP entries to console
new PerformanceObserver((list) => {
const entries = list.getEntries();
const lcp = entries[entries.length - 1];
console.log('LCP element:', lcp.element);
console.log('LCP time:', lcp.startTime, 'ms');
console.log('LCP size:', lcp.size, 'px²');
}).observe({ type: 'largest-contentful-paint', buffered: true });Timing the four sub-parts
// Decompose LCP into its phases
new PerformanceObserver((list) => {
const lcp = list.getEntries().at(-1);
// Get resource timing for the LCP image
const resources = performance.getEntriesByType('resource');
const lcpResource = resources.find(r => lcp.url && r.name === lcp.url);
if (lcpResource) {
const ttfb = performance.getEntriesByType('navigation')[0].responseStart;
const loadDelay = lcpResource.startTime - ttfb;
const loadDuration = lcpResource.responseEnd - lcpResource.startTime;
const renderDelay = lcp.startTime - lcpResource.responseEnd;
console.table({ ttfb, loadDelay, loadDuration, renderDelay });
}
}).observe({ type: 'largest-contentful-paint', buffered: true });Common LCP DevTools checks
- Waterfall: Is the LCP resource starting late? (should start < 500ms after navigation)
- Priority: Is the LCP resource marked as "Highest" priority? (should be)
- Coverage tab: Is there unused CSS blocking render?
- Rendering > Paint flashing: Watch what paints when - LCP should paint early
lighthouse-ci.md
Lighthouse CI and Performance Monitoring Reference
Lighthouse CI Setup (GitHub Actions)
Lighthouse CI (LHCI) runs Lighthouse against staging URLs on every PR and fails the build when performance regressions occur.
Installation
npm install --save-dev @lhci/cliBasic GitHub Actions workflow
# .github/workflows/lighthouse.yml
name: Lighthouse CI
on:
pull_request:
branches: [main]
jobs:
lighthouse:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 20
- name: Install dependencies
run: npm ci
- name: Build
run: npm run build
- name: Serve and run Lighthouse CI
run: |
npm run start &
sleep 5
npx lhci autorun
env:
LHCI_GITHUB_APP_TOKEN: ${{ secrets.LHCI_GITHUB_APP_TOKEN }}For deployed staging environments, skip the build/serve steps:
# .github/workflows/lighthouse-staging.yml
- name: Run Lighthouse CI against staging
uses: treosh/lighthouse-ci-action@v11
with:
urls: |
https://staging.example.com/
https://staging.example.com/products/
https://staging.example.com/checkout/
budgetPath: ./lighthouse-budget.json
uploadArtifacts: truelighthouserc.js configuration
// lighthouserc.js
module.exports = {
ci: {
collect: {
url: [
'http://localhost:3000/',
'http://localhost:3000/products/',
'http://localhost:3000/checkout/',
],
startServerCommand: 'npm run start',
startServerReadyPattern: 'ready on',
numberOfRuns: 3, // run 3x to get stable median
settings: {
// Simulate mobile network + CPU throttling (matches Lighthouse default)
preset: 'desktop', // or 'mobile' for mobile simulation
throttlingMethod: 'simulate',
throttling: {
rttMs: 40,
throughputKbps: 10240,
cpuSlowdownMultiplier: 1,
},
},
},
assert: {
preset: 'lighthouse:recommended',
assertions: {
// CWV assertions - fail CI if these thresholds are breached
'largest-contentful-paint': ['error', { maxNumericValue: 2500 }],
'total-blocking-time': ['error', { maxNumericValue: 200 }],
'cumulative-layout-shift': ['error', { maxNumericValue: 0.1 }],
// Warn (not fail) on these
'first-contentful-paint': ['warn', { maxNumericValue: 1800 }],
'speed-index': ['warn', { maxNumericValue: 3400 }],
// Score-based thresholds
'categories:performance': ['error', { minScore: 0.9 }],
'categories:accessibility': ['error', { minScore: 0.95 }],
// Turn off specific audits that don't apply
'uses-http2': 'off',
},
},
upload: {
target: 'lhci',
serverBaseUrl: process.env.LHCI_SERVER_URL,
token: process.env.LHCI_TOKEN,
},
},
};Performance Budget JSON Schema
A separate budget file gives more control over resource sizes and individual metric targets.
// lighthouse-budget.json
[
{
"path": "/*",
"timings": [
{ "metric": "first-contentful-paint", "budget": 1800 },
{ "metric": "largest-contentful-paint", "budget": 2500 },
{ "metric": "total-blocking-time", "budget": 200 },
{ "metric": "cumulative-layout-shift", "budget": 0.1 },
{ "metric": "speed-index", "budget": 3400 }
],
"resourceSizes": [
{ "resourceType": "document", "budget": 50 },
{ "resourceType": "script", "budget": 200 },
{ "resourceType": "stylesheet", "budget": 50 },
{ "resourceType": "image", "budget": 500 },
{ "resourceType": "font", "budget": 100 },
{ "resourceType": "third-party", "budget": 100 },
{ "resourceType": "total", "budget": 1000 }
],
"resourceCounts": [
{ "resourceType": "third-party", "budget": 10 },
{ "resourceType": "script", "budget": 20 }
]
},
{
"path": "/checkout/*",
"timings": [
{ "metric": "largest-contentful-paint", "budget": 2000 },
{ "metric": "total-blocking-time", "budget": 150 }
]
}
]Budget field reference:
timings.budget: milliseconds for timing metrics; decimal for CLS scoreresourceSizes.budget: kilobytes (KB)resourceCounts.budget: number of requests
Assertion Configuration Reference
LHCI assertions map Lighthouse audit IDs to pass/fail criteria.
assertions: {
// Levels: 'error' (fails CI), 'warn' (warning only), 'off' (ignore)
// Metric upper bounds (fail if metric EXCEEDS value)
'largest-contentful-paint': ['error', { maxNumericValue: 2500 }],
'total-blocking-time': ['error', { maxNumericValue: 200 }],
'cumulative-layout-shift': ['error', { maxNumericValue: 0.1 }],
// Score lower bounds (fail if score BELOW threshold, 0-1)
'categories:performance': ['error', { minScore: 0.9 }],
'categories:seo': ['warn', { minScore: 0.95 }],
}Aggregation methods (for multiple runs): median-run (recommended - most stable),
optimistic (best result - can mask flakiness), pessimistic (worst - conservative).
CrUX API Integration
Use the CrUX API to pull real user data into monitoring scripts and CI checks.
Fetch field data for a URL
// scripts/check-crux.js
const API_KEY = process.env.CRUX_API_KEY;
async function getCruxData(url) {
const response = await fetch(
`https://chromeuxreport.googleapis.com/v1/records:queryRecord?key=${API_KEY}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
url,
metrics: [
'largest_contentful_paint',
'interaction_to_next_paint',
'cumulative_layout_shift',
'first_contentful_paint',
'experimental_time_to_first_byte',
],
formFactor: 'PHONE', // PHONE, DESKTOP, or omit for ALL
}),
}
);
if (!response.ok) {
// URL may not have enough CrUX data (< 100 users in 28 days)
throw new Error(`CrUX API error: ${response.status}`);
}
return response.json();
}
function checkCwvAssessment(cruxData) {
const { metrics } = cruxData.record;
const thresholds = {
largest_contentful_paint: 2500,
interaction_to_next_paint: 200,
cumulative_layout_shift: 0.1,
};
const results = {};
for (const [metric, threshold] of Object.entries(thresholds)) {
const p75 = metrics[metric]?.percentiles?.p75;
if (p75 !== undefined) {
results[metric] = {
p75,
status: p75 <= threshold ? 'GOOD' : p75 <= threshold * 1.6 ? 'NI' : 'POOR',
};
}
}
return results;
}
// Run as a CI check or cron job
const url = process.argv[2] || 'https://example.com/';
const data = await getCruxData(url);
const assessment = checkCwvAssessment(data);
console.table(assessment);
const hasPoorMetric = Object.values(assessment).some(r => r.status === 'POOR');
process.exit(hasPoorMetric ? 1 : 0);CrUX History API (trend tracking)
Use https://chromeuxreport.googleapis.com/v1/records:queryHistoryRecord with the same
POST body for 25 weeks of trend data. Zip collectionPeriods with percentilesTimeseries.p75s
to plot LCP/CLS trends over time.
RUM Setup with web-vitals
Capture real user CWV data from production browsers and send to your analytics pipeline.
Full RUM integration
// src/lib/vitals.js
import { onLCP, onINP, onCLS, onFCP, onTTFB } from 'web-vitals';
function sendVital({ name, value, rating, id, delta, navigationType }) {
const body = {
name,
value: Math.round(name === 'CLS' ? value * 1000 : value), // normalize to integers
rating, // 'good' | 'needs-improvement' | 'poor'
id, // unique ID for deduplication
delta, // change since last report
navigationType, // 'navigate' | 'reload' | 'back-forward' | 'prerender'
url: location.href,
connection: navigator.connection?.effectiveType, // device segmentation
};
navigator.sendBeacon('/api/vitals', JSON.stringify(body)); // non-blocking
}
onLCP(sendVital);
onINP(sendVital, { reportAllChanges: true });
onCLS(sendVital, { reportAllChanges: true });
onFCP(sendVital);
onTTFB(sendVital);Attribution for debugging
When a metric is poor, attribution data helps identify the specific element and code path.
// web-vitals/attribution provides element selectors and timing breakdowns
import { onINP } from 'web-vitals/attribution';
onINP(({ value, attribution }) => {
sendVital({
name: 'INP',
value,
rating: value < 200 ? 'good' : value < 500 ? 'needs-improvement' : 'poor',
// Attribution fields
inp_target: attribution.interactionTarget, // CSS selector of clicked element
inp_type: attribution.interactionType, // 'pointer' | 'keyboard'
inp_input_delay: attribution.inputDelay, // ms
inp_processing: attribution.processingDuration, // ms
inp_presentation: attribution.presentationDelay, // ms
inp_script_url: attribution.longAnimationFrameEntries?.[0]?.scripts?.[0]?.sourceURL,
});
}, { reportAllChanges: true });
import { onLCP } from 'web-vitals/attribution';
onLCP(({ value, attribution }) => {
sendVital({
name: 'LCP',
value,
// Attribution fields
lcp_element: attribution.element, // CSS selector
lcp_url: attribution.url, // resource URL
lcp_ttfb: attribution.timeToFirstByte, // ms
lcp_load_delay: attribution.resourceLoadDelay, // ms
lcp_load_duration: attribution.resourceLoadDuration, // ms
lcp_render_delay: attribution.elementRenderDelay, // ms
});
});Alerting on CWV regressions
Set up monitoring that fires alerts when real-user metrics degrade.
// Example: simple threshold alerting using CrUX API on a schedule (cron / serverless function)
export async function checkCwvAlert() {
const pages = [
'https://example.com/',
'https://example.com/products/',
];
const alerts = [];
for (const url of pages) {
try {
const data = await getCruxData(url);
const { metrics } = data.record;
const lcp = metrics.largest_contentful_paint?.percentiles?.p75;
const inp = metrics.interaction_to_next_paint?.percentiles?.p75;
const cls = metrics.cumulative_layout_shift?.percentiles?.p75;
if (lcp > 2500) alerts.push({ url, metric: 'LCP', value: lcp, threshold: 2500 });
if (inp > 200) alerts.push({ url, metric: 'INP', value: inp, threshold: 200 });
if (cls > 0.1) alerts.push({ url, metric: 'CLS', value: cls, threshold: 0.1 });
} catch {
// Page may not have enough CrUX data yet
}
}
if (alerts.length > 0) {
await sendSlackAlert(alerts);
// Or: create Jira/GitHub issues, page on-call, update status page
}
}
async function sendSlackAlert(alerts) {
const text = alerts.map(a =>
`:red_circle: ${a.metric} degraded on ${a.url}: ${a.value} (threshold: ${a.threshold})`
).join('\n');
await fetch(process.env.SLACK_WEBHOOK_URL, {
method: 'POST',
body: JSON.stringify({ text: `*CWV Alert*\n${text}` }),
headers: { 'Content-Type': 'application/json' },
});
}Google Analytics 4 integration
For GA4: call gtag('event', name, { value, metric_rating: rating, non_interaction: true })
inside each callback. Use reportAllChanges: false to send only the final value per session.
Build a custom GA4 report with event_name and metric_rating dimensions, segmented by device.
Frequently Asked Questions
What is core-web-vitals?
Use this skill when optimizing Core Web Vitals - LCP (Largest Contentful Paint), INP (Interaction to Next Paint), and CLS (Cumulative Layout Shift). Triggers on page speed optimization, Lighthouse score improvement, fixing layout shifts, improving responsiveness, setting up performance monitoring with CrUX or RUM, and framework-specific CWV fixes for Next.js, Nuxt, Astro, and Remix.
How do I install core-web-vitals?
Run npx skills add AbsolutelySkilled/AbsolutelySkilled --skill core-web-vitals in your terminal. The skill will be immediately available in your AI coding agent.
What AI agents support core-web-vitals?
core-web-vitals works with claude-code, gemini-cli, openai-codex, mcp. Install it once and use it across any supported AI coding agent.