
Core Web Vitals
- 14 installs
- 213 repo stars
- Updated August 4, 2026
- yonatangross/orchestkit
Helps with ai & agent building tasks.
About
core-web-vitals is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- core-web-vitals
- AI & Agent Building
- AI-coding skill
Core Web Vitals by the numbers
- 14 all-time installs (skills.sh)
- Ranked #11,275 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/yonatangross/orchestkit --skill core-web-vitalsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 14 |
|---|---|
| repo stars | ★ 213 |
| Last updated | August 4, 2026 |
| Repository | yonatangross/orchestkit ↗ |
What it does
Helps with ai & agent building tasks.
Files
Core Web Vitals
Performance optimization for Google's Core Web Vitals - LCP, INP, CLS with 2026 thresholds.
Core Web Vitals Thresholds (2026)
| Metric | Good | Needs Improvement | Poor |
|---|---|---|---|
| LCP (Largest Contentful Paint) | ≤ 2.5s | ≤ 4.0s | > 4.0s |
| INP (Interaction to Next Paint) | ≤ 200ms | ≤ 500ms | > 500ms |
| CLS (Cumulative Layout Shift) | ≤ 0.1 | ≤ 0.25 | > 0.25 |
Note: INP replaced FID (First Input Delay) in March 2024 as the official responsiveness metric.
Upcoming 2026 Stricter Thresholds (Q4 2025 rollout)
| Metric | Current Good | 2026 Good |
|---|---|---|
| LCP | ≤ 2.5s | ≤ 2.0s |
| INP | ≤ 200ms | ≤ 150ms |
| CLS | ≤ 0.1 | ≤ 0.08 |
Plan for stricter thresholds now to maintain search rankings.
LCP Optimization
1. Identify LCP Element
// Find LCP element in DevTools
new PerformanceObserver((entryList) => {
const entries = entryList.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 });2. Optimize LCP Images
// Priority loading for hero image
<img
src="/hero.webp"
alt="Hero"
fetchpriority="high"
loading="eager"
decoding="async"
/>
// Next.js Image with priority
import Image from 'next/image';
<Image
src="/hero.webp"
alt="Hero"
priority
sizes="100vw"
quality={85}
/>3. Preload Critical Resources
<!-- Preload LCP image -->
<link rel="preload" as="image" href="/hero.webp" fetchpriority="high" />
<!-- Preload critical font -->
<link rel="preload" as="font" href="/fonts/inter.woff2" type="font/woff2" crossorigin />
<!-- Preconnect to critical origins -->
<link rel="preconnect" href="https://api.example.com" />
<link rel="dns-prefetch" href="https://analytics.example.com" />4. Server-Side Rendering
// Next.js - ensure SSR for LCP content
export default async function Page() {
const data = await fetchCriticalData();
return <HeroSection data={data} />; // Rendered on server
}
// Avoid client-only LCP content
// BAD: LCP content loaded client-side
const [data, setData] = useState(null);
useEffect(() => { fetchData().then(setData); }, []);INP Optimization
1. Break Up Long Tasks
// BAD: Long synchronous task (blocks main thread)
function processLargeArray(items: Item[]) {
items.forEach(processItem); // Blocks for entire duration
}
// GOOD: Yield to main thread
async function processLargeArray(items: Item[]) {
for (const item of items) {
processItem(item);
// Yield every 50ms to allow paint
if (performance.now() % 50 < 1) {
await scheduler.yield?.() ?? new Promise(r => setTimeout(r, 0));
}
}
}2. Use Transitions for Non-Urgent Updates
import { useTransition, useDeferredValue } from 'react';
function SearchResults() {
const [query, setQuery] = useState('');
const [isPending, startTransition] = useTransition();
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
// Urgent: Update input immediately
setQuery(e.target.value);
// Non-urgent: Defer expensive filter
startTransition(() => {
setFilteredResults(filterResults(e.target.value));
});
};
return (
<>
<input value={query} onChange={handleChange} />
{isPending && <Spinner />}
<ResultsList results={filteredResults} />
</>
);
}3. Optimize Event Handlers
// BAD: Heavy computation in click handler
<button onClick={() => {
const result = heavyComputation(); // Blocks paint
setResult(result);
}}>Calculate</button>
// GOOD: Defer heavy work
<button onClick={() => {
setLoading(true);
requestIdleCallback(() => {
const result = heavyComputation();
setResult(result);
setLoading(false);
});
}}>Calculate</button>CLS Optimization
1. Reserve Space for Dynamic Content
/* Reserve space for images */
.image-container {
aspect-ratio: 16 / 9;
width: 100%;
}
/* Reserve space for ads */
.ad-slot {
min-height: 250px;
}2. Explicit Dimensions
// Always set width and height
<img src="/photo.jpg" width={800} height={600} alt="Photo" />
// Next.js Image handles this automatically
<Image src="/photo.jpg" width={800} height={600} alt="Photo" />
// For responsive images
<Image src="/photo.jpg" fill sizes="(max-width: 768px) 100vw, 50vw" />3. Avoid Layout-Shifting Fonts
/* Use font-display: optional for non-critical fonts */
@font-face {
font-family: 'CustomFont';
src: url('/fonts/custom.woff2') format('woff2');
font-display: optional; /* Prevents flash of unstyled text */
}
/* Or use size-adjust for fallback */
@font-face {
font-family: 'Fallback';
src: local('Arial');
size-adjust: 105%;
ascent-override: 95%;
}4. Animations That Don't Cause Layout Shift
/* BAD: Changes layout properties */
.expanding {
height: 0;
transition: height 0.3s;
}
.expanding.open {
height: 200px; /* Causes layout shift */
}
/* GOOD: Use transform */
.expanding {
transform: scaleY(0);
transform-origin: top;
transition: transform 0.3s;
}
.expanding.open {
transform: scaleY(1);
}Real User Monitoring (RUM)
// web-vitals library
import { onLCP, onINP, onCLS } from 'web-vitals';
function sendToAnalytics(metric: Metric) {
fetch('/api/vitals', {
method: 'POST',
body: JSON.stringify({
name: metric.name,
value: metric.value,
rating: metric.rating,
navigationType: metric.navigationType,
}),
keepalive: true, // Send even if page unloads
});
}
onLCP(sendToAnalytics);
onINP(sendToAnalytics);
onCLS(sendToAnalytics);Performance Budgets
// lighthouse-budget.json
{
"resourceSizes": [
{ "resourceType": "script", "budget": 150 },
{ "resourceType": "image", "budget": 300 },
{ "resourceType": "total", "budget": 500 }
],
"timings": [
{ "metric": "largest-contentful-paint", "budget": 2500 },
{ "metric": "cumulative-layout-shift", "budget": 0.1 }
]
}// webpack-budget.config.js
module.exports = {
performance: {
maxAssetSize: 150000, // 150kb
maxEntrypointSize: 250000, // 250kb
hints: 'error', // Fail build if exceeded
},
};Debugging Tools
| Tool | Use Case |
|---|---|
| Chrome DevTools Performance | Identify long tasks, layout shifts |
| Lighthouse | Lab data, recommendations |
| PageSpeed Insights | Field data + lab data |
| Web Vitals Extension | Real-time vitals overlay |
| Chrome UX Report | Real user data by origin |
Quick Reference
// ✅ LCP: Preload and prioritize hero image
<link rel="preload" as="image" href="/hero.webp" fetchpriority="high" />
<Image src="/hero.webp" priority fill sizes="100vw" />
// ✅ INP: Use transitions for expensive updates
const [isPending, startTransition] = useTransition();
const deferredQuery = useDeferredValue(query);
// ✅ CLS: Always set dimensions, reserve space
<img src="/photo.jpg" width={800} height={600} alt="Photo" />
<div className="min-h-[250px]">{/* Reserved space */}</div>
// ✅ RUM: Send metrics reliably
navigator.sendBeacon('/api/vitals', JSON.stringify(metric));
// ✅ Font loading: Prevent FOUT/FOIT
@font-face {
font-display: optional; // or swap with size-adjust
}
// ❌ NEVER: Client-side fetch for LCP content
useEffect(() => { fetchHeroData().then(setData); }, []);
// ❌ NEVER: Missing dimensions on images
<img src="/photo.jpg" alt="Photo" /> // Causes CLS
// ❌ NEVER: Heavy computation in event handlers
onClick={() => { heavyComputation(); setResult(result); }}Key Decisions
| Decision | Option A | Option B | Recommendation |
|---|---|---|---|
| LCP content rendering | Client-side | SSR/SSG | SSR/SSG - Critical content must be in initial HTML |
| Image format | JPEG/PNG | WebP/AVIF | WebP (AVIF for modern browsers) - 25-50% smaller |
| Font loading | swap | optional | optional for non-critical, swap with fallback metrics |
| INP optimization | Debounce | useTransition | useTransition - React 18+ native, better UX |
| Monitoring | Lab only | Lab + Field | Lab + Field - Real user data is ground truth |
| Performance budget | Soft warning | Hard fail | Hard fail in CI - Prevents regression |
Anti-Patterns (FORBIDDEN)
// ❌ FORBIDDEN: LCP element rendered client-side
function Hero() {
const [data, setData] = useState(null);
useEffect(() => {
fetchHeroContent().then(setData); // LCP waits for JS + fetch!
}, []);
return data ? <HeroImage src={data.image} /> : <Skeleton />;
}
// ❌ FORBIDDEN: Images without dimensions
<img src="/photo.jpg" alt="Photo" /> // Browser can't reserve space
// ✅ CORRECT: Always provide width/height
<img src="/photo.jpg" width={800} height={600} alt="Photo" />
// ❌ FORBIDDEN: Lazy loading LCP image
<img src="/hero.webp" loading="lazy" /> // Delays LCP!
// ✅ CORRECT: Eager load with high priority
<img src="/hero.webp" fetchpriority="high" loading="eager" />
// ❌ FORBIDDEN: Blocking main thread in handlers
<button onClick={() => {
const result = expensiveOperation(); // Blocks INP!
setResult(result);
}}>Calculate</button>
// ✅ CORRECT: Defer heavy work
<button onClick={() => {
startTransition(() => {
const result = expensiveOperation();
setResult(result);
});
}}>Calculate</button>
// ❌ FORBIDDEN: Layout-shifting animations
.sidebar {
width: 0;
transition: width 0.3s; // Causes layout shift!
}
// ✅ CORRECT: Use transform
.sidebar {
transform: translateX(-100%);
transition: transform 0.3s;
}
// ❌ FORBIDDEN: Inserting content above viewport
function Banner() {
const [show, setShow] = useState(false);
useEffect(() => {
setTimeout(() => setShow(true), 1000); // CLS!
}, []);
return show ? <div className="fixed top-0">Banner</div> : null;
}
// ❌ FORBIDDEN: Font flash without fallback
@font-face {
font-family: 'Custom';
src: url('/custom.woff2');
font-display: block; // Shows nothing until font loads
}
// ❌ FORBIDDEN: Only measuring in lab environment
// Lab data != real user experience
// Always combine Lighthouse with RUM (web-vitals library)
// ❌ FORBIDDEN: Third-party scripts blocking render
<script src="https://slow-analytics.com/script.js"></script>
// ✅ CORRECT: Defer or async non-critical scripts
<script src="https://analytics.com/script.js" defer></script>Related Skills
image-optimization- Comprehensive image optimization strategiesobservability-monitoring- Production monitoring and alertingreact-server-components-framework- SSR/RSC for LCP optimizationfrontend-ui-developer- Modern frontend patternsaccessibility-specialist- Performance intersects with a11y (skip links, focus management)
Capability Details
lcp-optimization
Keywords: LCP, largest-contentful-paint, hero, preload, priority, SSR, TTFB Solves: Slow initial render, delayed hero content, poor Time to First Byte
inp-optimization
Keywords: INP, interaction, responsiveness, long-task, transition, yield, scheduler Solves: Slow button responses, janky scrolling, blocked main thread
cls-prevention
Keywords: CLS, layout-shift, dimensions, aspect-ratio, font-display, skeleton Solves: Content jumping, image pop-in, font flash, ad insertion shifts
rum-monitoring
Keywords: RUM, web-vitals, field-data, analytics, sendBeacon, percentile Solves: Understanding real user experience, identifying regressions, alerting
performance-budgets
Keywords: budget, webpack, lighthouse-ci, bundle-size, threshold, regression Solves: Preventing performance degradation, enforcing standards, CI integration
2026-thresholds
Keywords: 2026, stricter, LCP-2.0s, INP-150ms, CLS-0.08, future-proof Solves: Preparing for Google's stricter thresholds before they become ranking factors
References
references/rum-setup.md- Complete RUM implementationscripts/performance-monitoring.ts- Monitoring templatechecklists/cwv-checklist.md- Optimization checklistexamples/cwv-examples.md- Real-world optimization examples
Core Web Vitals Optimization Checklist
Comprehensive checklist for achieving and maintaining good Core Web Vitals scores.
Thresholds Reference
| Metric | Good | Needs Improvement | Poor |
|---|---|---|---|
| LCP | ≤ 2.5s | ≤ 4.0s | > 4.0s |
| INP | ≤ 200ms | ≤ 500ms | > 500ms |
| CLS | ≤ 0.1 | ≤ 0.25 | > 0.25 |
2026 Stricter Thresholds (plan ahead!):
- LCP: ≤ 2.0s
- INP: ≤ 150ms
- CLS: ≤ 0.08
---
LCP (Largest Contentful Paint) ≤ 2.5s
Identify the LCP Element
- [ ] Run Lighthouse to identify LCP element
- [ ] Use Performance Observer to confirm in production
- [ ] LCP is typically: hero image, hero heading, or above-the-fold banner
// Debug: Find LCP element
new PerformanceObserver((list) => {
const entries = list.getEntries();
console.log('LCP element:', entries[entries.length - 1].element);
}).observe({ type: 'largest-contentful-paint', buffered: true });Server Response Time (TTFB)
- [ ] Server response time (TTFB) < 800ms
- [ ] Use edge/CDN for static content
- [ ] Enable HTTP/2 or HTTP/3
- [ ] Compress responses (gzip/brotli)
- [ ] Database queries optimized
- [ ] Caching strategy implemented (Redis, CDN cache)
Critical Resource Loading
- [ ] LCP image has
fetchpriority="high"attribute - [ ] LCP image has
loading="eager"(not lazy) - [ ] LCP image preloaded in
<head> - [ ] Critical CSS inlined or preloaded
- [ ] Font preloaded with
crossoriginattribute - [ ] Preconnect to critical third-party origins
<!-- Preload critical resources -->
<link rel="preload" as="image" href="/hero.webp" fetchpriority="high" />
<link rel="preload" as="font" href="/font.woff2" type="font/woff2" crossorigin />
<link rel="preconnect" href="https://api.example.com" />Image Optimization
- [ ] LCP image in modern format (WebP/AVIF)
- [ ] Image properly sized (not oversized)
- [ ] Responsive images with
srcset - [ ] Image CDN used (Cloudinary, imgix, Vercel)
Rendering Strategy
- [ ] LCP content rendered server-side (SSR/SSG)
- [ ] LCP content NOT loaded client-side via fetch
- [ ] No render-blocking JavaScript
- [ ] No render-blocking CSS below the fold
- [ ] Third-party scripts deferred
// ✅ GOOD: Server-rendered LCP content
export default async function Page() {
const hero = await getHeroData();
return <Hero data={hero} />;
}
// ❌ BAD: Client-loaded LCP content
function Page() {
const [hero, setHero] = useState(null);
useEffect(() => { fetchHero().then(setHero); }, []); // Delays LCP!
}---
INP (Interaction to Next Paint) ≤ 200ms
Identify Long Tasks
- [ ] Chrome DevTools Performance tab analyzed
- [ ] Long tasks (>50ms) identified
- [ ] Main thread blockers removed/optimized
JavaScript Optimization
- [ ] Heavy computation moved to Web Workers
- [ ] Large arrays processed in chunks with yielding
- [ ]
requestIdleCallbackused for non-critical work - [ ] Bundle size minimized (code splitting)
- [ ] Tree shaking enabled
// ✅ GOOD: Yield to main thread
async function processItems(items: Item[]) {
for (const item of items) {
processItem(item);
// Yield every 4ms to allow paint
await scheduler.yield?.() ?? new Promise(r => setTimeout(r, 0));
}
}React Optimization
- [ ]
useTransitionfor non-urgent updates - [ ]
useDeferredValuefor expensive derivations - [ ] Memoization where appropriate (
useMemo,memo) - [ ] Virtualization for long lists (
react-window,@tanstack/virtual) - [ ] Suspense boundaries for code splitting
// ✅ GOOD: Non-blocking state updates
const [isPending, startTransition] = useTransition();
function handleSearch(query: string) {
setQuery(query); // Urgent: update input
startTransition(() => {
setFilteredResults(filter(query)); // Non-urgent: defer
});
}Event Handler Optimization
- [ ] No heavy computation in event handlers
- [ ] Handlers don't cause layout thrashing
- [ ] Passive event listeners for scroll/touch
- [ ] Debounced input handlers where appropriate
// ✅ GOOD: Defer heavy work
onClick={() => {
setLoading(true);
startTransition(() => {
const result = heavyComputation();
setResult(result);
setLoading(false);
});
}}
// ❌ BAD: Blocking handler
onClick={() => {
const result = heavyComputation(); // Blocks paint!
setResult(result);
}}Animation Performance
- [ ] Animations use
transformandopacityonly - [ ] No animations on layout properties (width, height, top, left)
- [ ]
will-changeused sparingly - [ ] Animations run at 60fps (checked in DevTools)
---
CLS (Cumulative Layout Shift) ≤ 0.1
Image Dimensions
- [ ] ALL images have explicit
widthandheight - [ ] Responsive images use
aspect-ratiocontainer - [ ]
fillprop images have sized container - [ ] No images cause layout shift on load
// ✅ GOOD: Explicit dimensions
<img src="/photo.jpg" width={800} height={600} alt="Photo" />
// ✅ GOOD: Aspect ratio container
<div className="aspect-[16/9]">
<Image src="/photo.jpg" fill alt="Photo" />
</div>Dynamic Content
- [ ] Space reserved for dynamic content (ads, embeds)
- [ ] Skeleton loaders match final content size
- [ ] No content inserted above existing content
- [ ] Lazy-loaded content has reserved space
// ✅ GOOD: Reserved space
<div className="min-h-[250px]">
{ad ? <Ad data={ad} /> : <Skeleton height={250} />}
</div>Font Loading
- [ ]
font-display: optionalorswapused - [ ] Fallback font has
size-adjustto match - [ ] Critical font preloaded
- [ ] System font stack as fallback
/* Fallback with size adjustment */
@font-face {
font-family: 'Inter Fallback';
src: local('Arial');
size-adjust: 107%;
ascent-override: 90%;
}
body {
font-family: 'Inter', 'Inter Fallback', sans-serif;
}Animation Stability
- [ ] Animations use
transform, not layout properties - [ ] Expanding/collapsing uses
scaleY, notheight - [ ] Modals/overlays don't shift page content
- [ ] Toast notifications positioned fixed/absolute
/* ✅ GOOD: Transform-based animation */
.drawer {
transform: translateX(-100%);
transition: transform 0.3s;
}
.drawer.open {
transform: translateX(0);
}
/* ❌ BAD: Layout-shifting animation */
.drawer {
width: 0;
transition: width 0.3s;
}Iframes and Embeds
- [ ] Iframes have explicit dimensions
- [ ] Third-party embeds wrapped with sized container
- [ ] Lazy iframes have placeholder
---
Measurement & Monitoring
Lab Testing
- [ ] Lighthouse CI in build pipeline
- [ ] Performance budgets enforced
- [ ] Regular manual Lighthouse audits
- [ ] Testing on throttled CPU/network
Field Data (RUM)
- [ ]
web-vitalslibrary installed - [ ] Metrics sent to analytics endpoint
- [ ] p75 percentile tracked (Google's standard)
- [ ] Alerts configured for regressions
// Essential RUM setup
import { onLCP, onINP, onCLS } from 'web-vitals';
onLCP(sendToAnalytics);
onINP(sendToAnalytics);
onCLS(sendToAnalytics);Data Analysis
- [ ] Dashboard showing daily/weekly trends
- [ ] Segmentation by page, device, connection
- [ ] Comparison of lab vs field data
- [ ] Week-over-week regression detection
Alerting
- [ ] Alert when p75 exceeds threshold
- [ ] Alert when good rate drops below 75%
- [ ] Alert on significant week-over-week regression
- [ ] Escalation path defined
---
Build & Deploy
Performance Budgets
- [ ] Bundle size limits configured
- [ ] Build fails on budget exceeded
- [ ] Per-route budgets for large apps
// webpack.config.js
module.exports = {
performance: {
maxAssetSize: 150000, // 150KB
maxEntrypointSize: 250000, // 250KB
hints: 'error', // Fail build
},
};CI/CD Integration
- [ ] Lighthouse CI runs on PRs
- [ ] Performance regression blocks merge
- [ ] Bundle analyzer report generated
- [ ] Preview deployments for testing
CDN & Caching
- [ ] Static assets on CDN
- [ ] Immutable caching for hashed assets
- [ ] Stale-while-revalidate for HTML
- [ ] Edge caching where appropriate
---
Debugging Checklist
Slow LCP
- [ ] Check TTFB (server response time)
- [ ] Verify LCP element has
fetchpriority="high" - [ ] Confirm LCP content is server-rendered
- [ ] Check for render-blocking resources
- [ ] Verify image is optimized and properly sized
High INP
- [ ] Run Performance recording during interaction
- [ ] Look for long tasks in flame chart
- [ ] Check for forced synchronous layouts
- [ ] Verify heavy work is deferred
- [ ] Check for excessive re-renders
High CLS
- [ ] Run Lighthouse with "Layout Shift Regions" enabled
- [ ] Check images for missing dimensions
- [ ] Look for late-loading content
- [ ] Verify fonts have fallbacks
- [ ] Check for content inserted above viewport
---
Testing Protocol
Before Deployment
- [ ] Lighthouse score ≥ 90 on Performance
- [ ] All Core Web Vitals in "good" range
- [ ] No performance budget violations
- [ ] Tested on throttled 4G + slow CPU
After Deployment
- [ ] Monitor RUM for 24-48 hours
- [ ] Compare p75 to pre-deployment baseline
- [ ] Check for unexpected regressions
- [ ] Verify alerting is working
Weekly Review
- [ ] Review p75 trends
- [ ] Identify worst-performing pages
- [ ] Check for new issues in CrUX
- [ ] Plan optimizations for next sprint
Core Web Vitals Examples
Real-world optimization examples for LCP, INP, and CLS.
---
1. LCP Optimization: E-Commerce Hero Section
Complete optimization of a hero section with product image and CTA.
Before: Slow LCP (3.5s+)
// ❌ BAD: Multiple LCP issues
function Hero() {
const [product, setProduct] = useState(null);
useEffect(() => {
// Problem 1: LCP content loaded client-side
fetch('/api/featured-product')
.then(res => res.json())
.then(setProduct);
}, []);
if (!product) return <div className="h-[600px]" />; // Problem 2: No skeleton
return (
<div className="relative">
{/* Problem 3: No priority, lazy by default */}
<img src={product.image} alt={product.name} />
<h1>{product.name}</h1>
<a href={`/product/${product.id}`}>Shop Now</a>
</div>
);
}After: Optimized LCP (1.2s)
// ✅ GOOD: Server-rendered with optimized image
import Image from 'next/image';
import { Suspense } from 'react';
// Server Component - data fetched on server
async function Hero() {
// Fetched on server, included in initial HTML
const product = await getFeaturedProduct();
return (
<section className="relative h-[600px] overflow-hidden">
{/* Priority image with explicit dimensions */}
<Image
src={product.image}
alt={product.name}
fill
priority // Preloads, eager loading
sizes="100vw"
quality={85}
placeholder="blur"
blurDataURL={product.blurPlaceholder}
style={{ objectFit: 'cover' }}
/>
{/* Content overlay */}
<div className="relative z-10 flex flex-col items-center justify-center h-full text-white">
<h1 className="text-5xl font-bold">{product.name}</h1>
<p className="mt-4 text-xl">{product.tagline}</p>
<a
href={`/product/${product.id}`}
className="mt-8 px-8 py-4 bg-white text-black rounded-lg font-semibold"
>
Shop Now
</a>
</div>
</section>
);
}
// Loading skeleton for Suspense boundary
function HeroSkeleton() {
return (
<section className="relative h-[600px] bg-gray-200 animate-pulse">
<div className="flex flex-col items-center justify-center h-full">
<div className="h-12 w-64 bg-gray-300 rounded" />
<div className="mt-4 h-6 w-48 bg-gray-300 rounded" />
<div className="mt-8 h-14 w-40 bg-gray-300 rounded-lg" />
</div>
</section>
);
}
// Usage in page
export default function HomePage() {
return (
<Suspense fallback={<HeroSkeleton />}>
<Hero />
</Suspense>
);
}
// Also add preload in head (layout.tsx or page metadata)
export const metadata = {
other: {
'link': [
{
rel: 'preload',
as: 'image',
href: '/featured-product-hero.webp',
fetchpriority: 'high',
},
],
},
};Document Head Optimizations
<!-- Add to <head> for fastest LCP -->
<head>
<!-- Preload hero image -->
<link rel="preload" as="image" href="/hero.webp" fetchpriority="high" />
<!-- Preload critical font -->
<link
rel="preload"
as="font"
href="/fonts/inter-bold.woff2"
type="font/woff2"
crossorigin
/>
<!-- Preconnect to image CDN -->
<link rel="preconnect" href="https://images.example.com" />
<!-- DNS prefetch for analytics -->
<link rel="dns-prefetch" href="https://analytics.example.com" />
</head>---
2. INP Optimization: Product Search Filter
Optimizing a search filter that was causing 400ms+ INP.
Before: Blocking INP (400ms+)
// ❌ BAD: Blocks main thread on every keystroke
function ProductSearch({ products }: { products: Product[] }) {
const [query, setQuery] = useState('');
const [results, setResults] = useState(products);
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setQuery(value);
// Problem: Expensive filter runs synchronously
// Blocks paint until complete
const filtered = products.filter(p =>
p.name.toLowerCase().includes(value.toLowerCase()) ||
p.description.toLowerCase().includes(value.toLowerCase()) ||
p.tags.some(t => t.toLowerCase().includes(value.toLowerCase()))
);
setResults(filtered);
};
return (
<>
<input
value={query}
onChange={handleChange}
placeholder="Search products..."
/>
<ProductGrid products={results} />
</>
);
}After: Responsive INP (50ms)
// ✅ GOOD: Non-blocking with useDeferredValue
import {
useState,
useDeferredValue,
useMemo,
useTransition,
memo
} from 'react';
function ProductSearch({ products }: { products: Product[] }) {
const [query, setQuery] = useState('');
const [isPending, startTransition] = useTransition();
// Deferred value for expensive computation
const deferredQuery = useDeferredValue(query);
const isStale = query !== deferredQuery;
// Memoized filter only runs when deferredQuery changes
const results = useMemo(() => {
if (!deferredQuery) return products;
const searchLower = deferredQuery.toLowerCase();
return products.filter(p =>
p.name.toLowerCase().includes(searchLower) ||
p.description.toLowerCase().includes(searchLower) ||
p.tags.some(t => t.toLowerCase().includes(searchLower))
);
}, [products, deferredQuery]);
return (
<div>
<div className="relative">
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search products..."
className="w-full px-4 py-2 border rounded-lg"
/>
{/* Loading indicator during filter */}
{isPending && (
<div className="absolute right-3 top-1/2 -translate-y-1/2">
<Spinner size="sm" />
</div>
)}
</div>
{/* Fade during pending state */}
<div
className="mt-4 transition-opacity"
style={{ opacity: isStale ? 0.7 : 1 }}
>
<ProductGrid products={results} />
</div>
</div>
);
}
// Memoized grid to prevent unnecessary re-renders
const ProductGrid = memo(function ProductGrid({
products
}: {
products: Product[]
}) {
return (
<div className="grid grid-cols-4 gap-4">
{products.map(product => (
<ProductCard key={product.id} product={product} />
))}
</div>
);
});For Very Large Lists: Virtual Scrolling
// ✅ BEST: Virtualization for huge lists
import { useVirtualizer } from '@tanstack/react-virtual';
function VirtualizedProductList({ products }: { products: Product[] }) {
const parentRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: products.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 200, // Estimated row height
overscan: 5, // Render 5 extra items above/below
});
return (
<div
ref={parentRef}
className="h-[600px] overflow-auto"
>
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative',
}}
>
{virtualizer.getVirtualItems().map((virtualRow) => (
<div
key={virtualRow.key}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`,
}}
>
<ProductCard product={products[virtualRow.index]} />
</div>
))}
</div>
</div>
);
}---
3. CLS Optimization: News Article Page
Fixing layout shifts from images, ads, and fonts.
Before: High CLS (0.35)
// ❌ BAD: Multiple CLS issues
function Article({ article }: { article: Article }) {
const [ad, setAd] = useState(null);
useEffect(() => {
loadAd().then(setAd);
}, []);
return (
<article>
<h1>{article.title}</h1>
{/* Problem 1: Image without dimensions */}
<img src={article.heroImage} alt="" />
{/* Problem 2: Ad appears after load, shifts content */}
{ad && <div className="ad-banner"><img src={ad.image} /></div>}
<div dangerouslySetInnerHTML={{ __html: article.content }} />
{/* Problem 3: Related articles load and shift */}
<RelatedArticles />
</article>
);
}
// Problem 4: Font causes layout shift
// CSS
/* No font-display, no fallback sizing */
@font-face {
font-family: 'CustomFont';
src: url('/font.woff2');
}After: Zero CLS (0.0)
// ✅ GOOD: All layout shifts prevented
import Image from 'next/image';
function Article({ article }: { article: Article }) {
return (
<article className="max-w-3xl mx-auto">
<h1 className="text-4xl font-bold">{article.title}</h1>
{/* Fixed dimensions prevent shift */}
<div className="relative aspect-[16/9] my-6">
<Image
src={article.heroImage}
alt={article.heroAlt}
fill
sizes="(max-width: 768px) 100vw, 768px"
priority
style={{ objectFit: 'cover' }}
/>
</div>
{/* Reserved space for ad */}
<AdSlot
slot="article-top"
className="my-6"
minHeight={250}
/>
<div
className="prose prose-lg"
dangerouslySetInnerHTML={{ __html: article.content }}
/>
{/* Reserved space for related */}
<RelatedArticles articleId={article.id} />
</article>
);
}
// Ad component with reserved space
function AdSlot({
slot,
className,
minHeight
}: {
slot: string;
className?: string;
minHeight: number;
}) {
const [ad, setAd] = useState<Ad | null>(null);
const [loaded, setLoaded] = useState(false);
useEffect(() => {
loadAd(slot).then(ad => {
setAd(ad);
setLoaded(true);
});
}, [slot]);
return (
<div
className={className}
style={{ minHeight: `${minHeight}px` }} // Reserved space
>
{loaded ? (
ad ? (
<Image
src={ad.image}
alt={ad.alt}
width={ad.width}
height={ad.height}
/>
) : null // No ad, space collapses gracefully
) : (
<Skeleton height={minHeight} /> // Placeholder during load
)}
</div>
);
}
// Related articles with skeleton
function RelatedArticles({ articleId }: { articleId: string }) {
const [articles, setArticles] = useState<Article[] | null>(null);
useEffect(() => {
fetchRelated(articleId).then(setArticles);
}, [articleId]);
return (
<section className="mt-12">
<h2 className="text-2xl font-bold mb-6">Related Articles</h2>
{/* Fixed grid prevents shift */}
<div className="grid grid-cols-3 gap-6">
{articles ? (
articles.map(article => (
<ArticleCard key={article.id} article={article} />
))
) : (
// Skeleton matches final layout exactly
<>
<ArticleCardSkeleton />
<ArticleCardSkeleton />
<ArticleCardSkeleton />
</>
)}
</div>
</section>
);
}
// Skeleton that matches card dimensions exactly
function ArticleCardSkeleton() {
return (
<div className="animate-pulse">
<div className="aspect-[16/9] bg-gray-200 rounded-lg" />
<div className="mt-3 h-5 bg-gray-200 rounded w-3/4" />
<div className="mt-2 h-4 bg-gray-200 rounded w-1/2" />
</div>
);
}Font Loading Without CLS
/* ✅ Optimized font loading */
/* Main font with swap and metrics */
@font-face {
font-family: 'Inter';
src: url('/fonts/inter-var.woff2') format('woff2');
font-display: swap;
font-weight: 100 900;
}
/* Fallback font with matched metrics */
@font-face {
font-family: 'Inter Fallback';
src: local('Arial');
size-adjust: 107.64%;
ascent-override: 90%;
descent-override: 22.43%;
line-gap-override: 0%;
}
body {
font-family: 'Inter', 'Inter Fallback', system-ui, sans-serif;
}
/* Alternative: font-display: optional for non-critical fonts */
@font-face {
font-family: 'DisplayFont';
src: url('/fonts/display.woff2') format('woff2');
font-display: optional; /* Won't cause FOUT - uses fallback if not cached */
}---
4. Complete RUM Implementation
Full Real User Monitoring setup with Next.js.
// lib/performance.ts
import { onCLS, onINP, onLCP, onFCP, onTTFB, type Metric } from 'web-vitals';
const ENDPOINT = '/api/vitals';
interface EnrichedMetric {
name: string;
value: number;
rating: 'good' | 'needs-improvement' | 'poor';
delta: number;
id: string;
navigationType: string;
url: string;
timestamp: number;
connection?: string;
deviceMemory?: number;
viewport: { width: number; height: number };
}
function getConnectionInfo() {
const nav = navigator as Navigator & {
connection?: { effectiveType?: string };
deviceMemory?: number;
};
return {
connection: nav.connection?.effectiveType,
deviceMemory: nav.deviceMemory,
};
}
function sendMetric(metric: Metric) {
const enriched: EnrichedMetric = {
name: metric.name,
value: metric.value,
rating: metric.rating,
delta: metric.delta,
id: metric.id,
navigationType: metric.navigationType,
url: window.location.href,
timestamp: Date.now(),
...getConnectionInfo(),
viewport: {
width: window.innerWidth,
height: window.innerHeight,
},
};
// Use sendBeacon for reliability
if (navigator.sendBeacon) {
navigator.sendBeacon(ENDPOINT, JSON.stringify(enriched));
} else {
fetch(ENDPOINT, {
method: 'POST',
body: JSON.stringify(enriched),
keepalive: true,
});
}
// Debug in development
if (process.env.NODE_ENV === 'development') {
const color = {
good: 'green',
'needs-improvement': 'orange',
poor: 'red',
}[metric.rating];
console.log(
`%c[${metric.name}] ${metric.value.toFixed(1)}${metric.name === 'CLS' ? '' : 'ms'}`,
`color: ${color}; font-weight: bold`
);
}
}
export function initWebVitals() {
onCLS(sendMetric);
onINP(sendMetric);
onLCP(sendMetric);
onFCP(sendMetric);
onTTFB(sendMetric);
}// app/components/web-vitals.tsx
'use client';
import { useEffect } from 'react';
import { initWebVitals } from '@/lib/performance';
export function WebVitals() {
useEffect(() => {
initWebVitals();
}, []);
return null;
}// app/api/vitals/route.ts
import { NextRequest, NextResponse } from 'next/server';
interface VitalMetric {
name: string;
value: number;
rating: string;
url: string;
timestamp: number;
}
export async function POST(request: NextRequest) {
const metric: VitalMetric = await request.json();
// Log for debugging
console.log('[Vital]', metric.name, metric.value, metric.rating);
// Store in database (example with Drizzle)
// await db.insert(webVitals).values({
// name: metric.name,
// value: metric.value,
// rating: metric.rating,
// url: metric.url,
// timestamp: new Date(metric.timestamp),
// });
// Alert on poor metrics
if (metric.rating === 'poor') {
// await alertService.send({
// severity: 'warning',
// message: `Poor ${metric.name}: ${metric.value} on ${metric.url}`,
// });
}
return NextResponse.json({ ok: true });
}---
5. Performance Budget Enforcement
CI/CD integration with Lighthouse CI.
lighthouserc.js
module.exports = {
ci: {
collect: {
url: [
'http://localhost:3000/',
'http://localhost:3000/products',
'http://localhost:3000/checkout',
],
numberOfRuns: 3,
settings: {
preset: 'desktop',
// Throttle to simulate 4G
// throttling: { ... }
},
},
assert: {
assertions: {
// Core Web Vitals
'largest-contentful-paint': ['error', { maxNumericValue: 2500 }],
'cumulative-layout-shift': ['error', { maxNumericValue: 0.1 }],
'total-blocking-time': ['error', { maxNumericValue: 200 }], // Proxy for INP
// Other performance metrics
'first-contentful-paint': ['warn', { maxNumericValue: 1800 }],
'speed-index': ['warn', { maxNumericValue: 3400 }],
// Resource budgets
'resource-summary:script:size': ['error', { maxNumericValue: 150000 }],
'resource-summary:image:size': ['error', { maxNumericValue: 300000 }],
'resource-summary:total:size': ['error', { maxNumericValue: 500000 }],
// Scores
'categories:performance': ['error', { minScore: 0.9 }],
'categories:accessibility': ['error', { minScore: 0.9 }],
},
},
upload: {
target: 'temporary-public-storage',
},
},
};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: Start server
run: npm start &
- name: Wait for server
run: npx wait-on http://localhost:3000
- name: Run Lighthouse CI
run: |
npm install -g @lhci/cli
lhci autorun
env:
LHCI_GITHUB_APP_TOKEN: ${{ secrets.LHCI_GITHUB_APP_TOKEN }}
- name: Upload results
uses: actions/upload-artifact@v4
with:
name: lighthouse-results
path: .lighthouseci/---
Quick Reference
// ✅ LCP: Server-render, preload, priority
export default async function Page() {
const data = await getData(); // Server-side
return <Image src={data.hero} priority fill />;
}
// ✅ INP: useTransition for expensive updates
const [isPending, startTransition] = useTransition();
onChange={(e) => {
setQuery(e.target.value);
startTransition(() => setResults(filter(e.target.value)));
}}
// ✅ CLS: Always set dimensions
<Image src="/photo.jpg" width={800} height={600} />
<div className="aspect-[16/9]"><Image fill /></div>
<div className="min-h-[250px]">{content}</div>
// ✅ RUM: Send metrics reliably
navigator.sendBeacon('/api/vitals', JSON.stringify(metric));
// ✅ Debug: Find LCP element
new PerformanceObserver((list) => {
console.log('LCP:', list.getEntries().at(-1)?.element);
}).observe({ type: 'largest-contentful-paint', buffered: true });Real User Monitoring (RUM) Setup
Complete guide to implementing Real User Monitoring for Core Web Vitals.
┌─────────────────────────────────────────────────────────────────────────┐
│ RUM Data Flow │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ Browser Server Analytics │
│ ┌────────┐ ┌────────┐ ┌────────────┐ │
│ │ User │──interaction──►│ web-vitals │ │ │ │
│ │Session │ │ library │ │ Dashboard │ │
│ └────────┘ └─────┬──────┘ │ + Alerts │ │
│ │ └─────┬──────┘ │
│ │ │ │
│ ┌────────────▼────────────┐ │ │
│ │ sendBeacon / fetch │─────────────► │
│ │ (keepalive: true) │ │ │
│ └────────────┬────────────┘ │ │
│ │ │ │
│ ┌────────────▼────────────┐ │ │
│ │ /api/vitals │─────────────► │
│ │ (batch + process) │ metrics │ │
│ └─────────────────────────┘ │ │
│ │
└─────────────────────────────────────────────────────────────────────────┘web-vitals Library Setup
Installation
npm install web-vitals
# or
pnpm add web-vitalsBasic Implementation
// lib/vitals.ts
import {
onCLS,
onINP,
onLCP,
onFCP,
onTTFB,
type Metric,
type ReportOpts,
} from 'web-vitals';
// Metric type for your analytics
export interface VitalsMetric {
name: 'CLS' | 'INP' | 'LCP' | 'FCP' | 'TTFB';
value: number;
rating: 'good' | 'needs-improvement' | 'poor';
delta: number;
id: string;
navigationType: 'navigate' | 'reload' | 'back-forward' | 'back-forward-cache' | 'prerender';
// Custom metadata
url: string;
userAgent: string;
connectionType?: string;
deviceMemory?: number;
timestamp: number;
}
// Collect device and connection info for debugging
function getDeviceInfo(): Partial<VitalsMetric> {
const nav = navigator as Navigator & {
connection?: { effectiveType?: string };
deviceMemory?: number;
};
return {
userAgent: navigator.userAgent,
connectionType: nav.connection?.effectiveType,
deviceMemory: nav.deviceMemory,
};
}
function createMetricPayload(metric: Metric): VitalsMetric {
return {
name: metric.name as VitalsMetric['name'],
value: metric.value,
rating: metric.rating,
delta: metric.delta,
id: metric.id,
navigationType: metric.navigationType,
url: window.location.href,
timestamp: Date.now(),
...getDeviceInfo(),
};
}
// Reliable transmission even during page unload
function sendToAnalytics(metric: Metric) {
const payload = createMetricPayload(metric);
const body = JSON.stringify(payload);
// sendBeacon is most reliable for unload scenarios
if (navigator.sendBeacon) {
navigator.sendBeacon('/api/vitals', body);
} else {
// Fallback with keepalive for browsers without sendBeacon
fetch('/api/vitals', {
method: 'POST',
body,
headers: { 'Content-Type': 'application/json' },
keepalive: true, // Keeps request alive even if page unloads
});
}
}
// Report all web vitals
export function reportWebVitals(opts?: ReportOpts) {
// Core Web Vitals (affect SEO)
onCLS(sendToAnalytics, opts);
onINP(sendToAnalytics, opts);
onLCP(sendToAnalytics, opts);
// Additional useful metrics
onFCP(sendToAnalytics, opts);
onTTFB(sendToAnalytics, opts);
}Next.js App Router Integration
Client Component for Vitals
// app/components/web-vitals.tsx
'use client';
import { useEffect } from 'react';
import { reportWebVitals } from '@/lib/vitals';
export function WebVitals() {
useEffect(() => {
// Report immediately (first value)
reportWebVitals({ reportAllChanges: false });
}, []);
return null;
}
// For debugging during development
export function WebVitalsDebug() {
useEffect(() => {
// Report all changes, not just final values
reportWebVitals({ reportAllChanges: true });
}, []);
return null;
}Layout Integration
// app/layout.tsx
import { WebVitals } from '@/components/web-vitals';
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>
<WebVitals />
{children}
</body>
</html>
);
}API Endpoint Implementation
Next.js Route Handler
// app/api/vitals/route.ts
import { NextRequest, NextResponse } from 'next/server';
// Thresholds from web.dev
const THRESHOLDS = {
LCP: { good: 2500, poor: 4000 },
INP: { good: 200, poor: 500 },
CLS: { good: 0.1, poor: 0.25 },
FCP: { good: 1800, poor: 3000 },
TTFB: { good: 800, poor: 1800 },
} as const;
// 2026 thresholds (plan ahead!)
const THRESHOLDS_2026 = {
LCP: { good: 2000, poor: 4000 },
INP: { good: 150, poor: 500 },
CLS: { good: 0.08, poor: 0.25 },
} as const;
interface VitalsMetric {
name: string;
value: number;
rating: string;
delta: number;
id: string;
navigationType: string;
url: string;
userAgent: string;
connectionType?: string;
deviceMemory?: number;
timestamp: number;
}
// Validate incoming metric
function isValidMetric(data: unknown): data is VitalsMetric {
if (!data || typeof data !== 'object') return false;
const metric = data as Record<string, unknown>;
return (
typeof metric.name === 'string' &&
typeof metric.value === 'number' &&
typeof metric.rating === 'string'
);
}
export async function POST(request: NextRequest) {
try {
const metric = await request.json();
if (!isValidMetric(metric)) {
return NextResponse.json(
{ error: 'Invalid metric format' },
{ status: 400 }
);
}
// Enrich with server-side data
const enrichedMetric = {
...metric,
receivedAt: new Date().toISOString(),
clientIP: request.headers.get('x-forwarded-for') ?? 'unknown',
country: request.headers.get('x-vercel-ip-country') ?? 'unknown',
};
// Log for debugging (replace with your analytics service)
console.log('[Web Vital]', JSON.stringify(enrichedMetric));
// Store in your analytics database
await storeMetric(enrichedMetric);
// Alert on poor metrics (optional)
if (metric.rating === 'poor') {
await alertOnPoorMetric(enrichedMetric);
}
return NextResponse.json({ received: true });
} catch (error) {
console.error('[Vitals API Error]', error);
return NextResponse.json(
{ error: 'Failed to process metric' },
{ status: 500 }
);
}
}
// Example: Store in PostgreSQL
async function storeMetric(metric: VitalsMetric & { receivedAt: string }) {
// Replace with your database client
// await db.insert('web_vitals').values({
// name: metric.name,
// value: metric.value,
// rating: metric.rating,
// url: metric.url,
// user_agent: metric.userAgent,
// connection_type: metric.connectionType,
// timestamp: new Date(metric.timestamp),
// received_at: new Date(metric.receivedAt),
// });
}
// Example: Alert via Slack/PagerDuty
async function alertOnPoorMetric(metric: VitalsMetric) {
const threshold = THRESHOLDS[metric.name as keyof typeof THRESHOLDS];
if (!threshold) return;
// await fetch(process.env.SLACK_WEBHOOK_URL!, {
// method: 'POST',
// body: JSON.stringify({
// text: `🚨 Poor ${metric.name}: ${metric.value}${metric.name === 'CLS' ? '' : 'ms'} on ${metric.url}`,
// }),
// });
}Batching for High-Traffic Sites
// lib/vitals-batched.ts
import { onCLS, onINP, onLCP, type Metric } from 'web-vitals';
const BATCH_SIZE = 10;
const FLUSH_INTERVAL = 5000; // 5 seconds
class MetricsBatcher {
private queue: Metric[] = [];
private flushTimer: ReturnType<typeof setTimeout> | null = null;
add(metric: Metric) {
this.queue.push(metric);
if (this.queue.length >= BATCH_SIZE) {
this.flush();
} else if (!this.flushTimer) {
this.flushTimer = setTimeout(() => this.flush(), FLUSH_INTERVAL);
}
}
private flush() {
if (this.queue.length === 0) return;
const metrics = [...this.queue];
this.queue = [];
if (this.flushTimer) {
clearTimeout(this.flushTimer);
this.flushTimer = null;
}
// Send batch
navigator.sendBeacon(
'/api/vitals/batch',
JSON.stringify({ metrics, timestamp: Date.now() })
);
}
// Flush on page unload
flushSync() {
if (this.flushTimer) {
clearTimeout(this.flushTimer);
this.flushTimer = null;
}
this.flush();
}
}
const batcher = new MetricsBatcher();
// Ensure flush on unload
if (typeof window !== 'undefined') {
window.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
batcher.flushSync();
}
});
}
export function reportWebVitalsBatched() {
onCLS((metric) => batcher.add(metric));
onINP((metric) => batcher.add(metric));
onLCP((metric) => batcher.add(metric));
}Database Schema
PostgreSQL Schema
CREATE TABLE web_vitals (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(10) NOT NULL,
value DECIMAL(10, 4) NOT NULL,
rating VARCHAR(20) NOT NULL,
delta DECIMAL(10, 4),
metric_id VARCHAR(50),
navigation_type VARCHAR(30),
url TEXT NOT NULL,
user_agent TEXT,
connection_type VARCHAR(20),
device_memory INT,
client_ip INET,
country VARCHAR(2),
timestamp TIMESTAMPTZ NOT NULL,
received_at TIMESTAMPTZ DEFAULT NOW(),
-- Indexes for common queries
INDEX idx_vitals_name_timestamp (name, timestamp DESC),
INDEX idx_vitals_url (url),
INDEX idx_vitals_rating (rating)
);
-- Partition by month for large datasets
CREATE TABLE web_vitals_2025_01 PARTITION OF web_vitals
FOR VALUES FROM ('2025-01-01') TO ('2025-02-01');Analytics Queries
-- Daily Core Web Vitals summary (p75 is Google's standard)
SELECT
DATE(timestamp) as date,
name,
PERCENTILE_CONT(0.75) WITHIN GROUP (ORDER BY value) as p75,
COUNT(CASE WHEN rating = 'good' THEN 1 END)::float / COUNT(*) * 100 as good_pct,
COUNT(*) as samples
FROM web_vitals
WHERE timestamp > NOW() - INTERVAL '30 days'
AND name IN ('LCP', 'INP', 'CLS')
GROUP BY DATE(timestamp), name
ORDER BY date DESC, name;
-- Worst performing pages by LCP
SELECT
url,
PERCENTILE_CONT(0.75) WITHIN GROUP (ORDER BY value) as p75_lcp,
COUNT(*) as samples
FROM web_vitals
WHERE name = 'LCP'
AND timestamp > NOW() - INTERVAL '7 days'
GROUP BY url
HAVING COUNT(*) > 100
ORDER BY p75_lcp DESC
LIMIT 20;
-- Performance by connection type
SELECT
connection_type,
name,
PERCENTILE_CONT(0.75) WITHIN GROUP (ORDER BY value) as p75,
COUNT(*) as samples
FROM web_vitals
WHERE timestamp > NOW() - INTERVAL '7 days'
AND connection_type IS NOT NULL
GROUP BY connection_type, name
ORDER BY connection_type, name;
-- Trend analysis: Week-over-week comparison
WITH current_week AS (
SELECT name, PERCENTILE_CONT(0.75) WITHIN GROUP (ORDER BY value) as p75
FROM web_vitals
WHERE timestamp > NOW() - INTERVAL '7 days'
GROUP BY name
),
previous_week AS (
SELECT name, PERCENTILE_CONT(0.75) WITHIN GROUP (ORDER BY value) as p75
FROM web_vitals
WHERE timestamp BETWEEN NOW() - INTERVAL '14 days' AND NOW() - INTERVAL '7 days'
GROUP BY name
)
SELECT
c.name,
c.p75 as current_p75,
p.p75 as previous_p75,
ROUND((c.p75 - p.p75) / p.p75 * 100, 2) as change_pct
FROM current_week c
JOIN previous_week p ON c.name = p.name;Grafana Dashboard
Prometheus Metrics Export
// lib/metrics-exporter.ts
import { Histogram, Counter, Registry } from 'prom-client';
const registry = new Registry();
// Histogram for percentile calculations
const webVitalsHistogram = new Histogram({
name: 'web_vitals_value',
help: 'Web Vitals metric values',
labelNames: ['name', 'rating'],
buckets: {
LCP: [1000, 1500, 2000, 2500, 3000, 4000, 5000],
INP: [50, 100, 150, 200, 300, 500, 1000],
CLS: [0.01, 0.05, 0.1, 0.15, 0.25, 0.5],
}['LCP'], // Default buckets
registers: [registry],
});
const webVitalsCounter = new Counter({
name: 'web_vitals_total',
help: 'Total count of Web Vitals reports',
labelNames: ['name', 'rating'],
registers: [registry],
});
export function recordMetric(name: string, value: number, rating: string) {
webVitalsHistogram.labels(name, rating).observe(value);
webVitalsCounter.labels(name, rating).inc();
}
export { registry };Grafana Alert Rules
# grafana-alerts.yaml
groups:
- name: core-web-vitals
interval: 5m
rules:
# LCP Alert
- alert: HighLCP
expr: histogram_quantile(0.75, sum(rate(web_vitals_value_bucket{name="LCP"}[15m])) by (le)) > 2500
for: 10m
labels:
severity: warning
annotations:
summary: "LCP p75 is {{ $value | printf \"%.0f\" }}ms (threshold: 2500ms)"
description: "Largest Contentful Paint has degraded. Check recent deployments."
# INP Alert
- alert: HighINP
expr: histogram_quantile(0.75, sum(rate(web_vitals_value_bucket{name="INP"}[15m])) by (le)) > 200
for: 10m
labels:
severity: warning
annotations:
summary: "INP p75 is {{ $value | printf \"%.0f\" }}ms (threshold: 200ms)"
description: "Interaction to Next Paint has degraded. Check for long tasks."
# CLS Alert
- alert: HighCLS
expr: histogram_quantile(0.75, sum(rate(web_vitals_value_bucket{name="CLS"}[15m])) by (le)) > 0.1
for: 10m
labels:
severity: warning
annotations:
summary: "CLS p75 is {{ $value | printf \"%.3f\" }} (threshold: 0.1)"
description: "Cumulative Layout Shift has degraded. Check for layout shifts."
# Good rate dropping
- alert: GoodRateDrop
expr: |
(sum(rate(web_vitals_total{rating="good"}[1h])) by (name) /
sum(rate(web_vitals_total[1h])) by (name)) < 0.75
for: 30m
labels:
severity: critical
annotations:
summary: "{{ $labels.name }} good rate dropped below 75%"
description: "Less than 75% of users are experiencing good {{ $labels.name }}"Sampling Strategy for High Traffic
// lib/vitals-sampled.ts
import { onCLS, onINP, onLCP, type Metric } from 'web-vitals';
interface SamplingConfig {
// Base sample rate (0-1)
baseRate: number;
// Always sample poor metrics
alwaysSamplePoor: boolean;
// Sample more on specific pages
pageMultipliers?: Record<string, number>;
}
const DEFAULT_CONFIG: SamplingConfig = {
baseRate: 0.1, // 10% baseline
alwaysSamplePoor: true,
pageMultipliers: {
'/': 1.0, // Always sample homepage
'/checkout': 1.0, // Always sample checkout
},
};
function shouldSample(metric: Metric, config: SamplingConfig): boolean {
// Always sample poor metrics for debugging
if (config.alwaysSamplePoor && metric.rating === 'poor') {
return true;
}
// Check page-specific multiplier
const path = window.location.pathname;
const multiplier = config.pageMultipliers?.[path] ?? 1;
const effectiveRate = config.baseRate * multiplier;
return Math.random() < effectiveRate;
}
export function reportWebVitalsSampled(config = DEFAULT_CONFIG) {
const report = (metric: Metric) => {
if (shouldSample(metric, config)) {
sendToAnalytics(metric);
}
};
onCLS(report);
onINP(report);
onLCP(report);
}Testing RUM in Development
// lib/vitals-dev.ts
import { onCLS, onINP, onLCP, type Metric } from 'web-vitals';
const RATING_COLORS = {
good: 'color: green',
'needs-improvement': 'color: orange',
poor: 'color: red',
} as const;
function logToConsole(metric: Metric) {
const color = RATING_COLORS[metric.rating];
const unit = metric.name === 'CLS' ? '' : 'ms';
console.log(
`%c[${metric.name}] ${metric.value.toFixed(2)}${unit} (${metric.rating})`,
color,
{
delta: metric.delta,
id: metric.id,
navigationType: metric.navigationType,
}
);
}
export function reportWebVitalsDev() {
// Report all changes for debugging
onCLS(logToConsole, { reportAllChanges: true });
onINP(logToConsole, { reportAllChanges: true });
onLCP(logToConsole, { reportAllChanges: true });
}
// Usage in development
if (process.env.NODE_ENV === 'development') {
reportWebVitalsDev();
}Integration with Analytics Providers
Google Analytics 4
// lib/vitals-ga4.ts
import { onCLS, onINP, onLCP, type Metric } from 'web-vitals';
declare global {
interface Window {
gtag?: (...args: unknown[]) => void;
}
}
function sendToGA4(metric: Metric) {
if (typeof window.gtag !== 'function') return;
window.gtag('event', metric.name, {
event_category: 'Web Vitals',
event_label: metric.id,
value: Math.round(metric.name === 'CLS' ? metric.value * 1000 : metric.value),
metric_rating: metric.rating,
non_interaction: true,
});
}
export function reportWebVitalsGA4() {
onCLS(sendToGA4);
onINP(sendToGA4);
onLCP(sendToGA4);
}Vercel Analytics
// Next.js built-in support
// next.config.js
module.exports = {
// Vercel Analytics automatically collects Web Vitals
// No additional setup needed when deployed on Vercel
};
// For self-hosted, use @vercel/analytics
import { Analytics } from '@vercel/analytics/react';
export default function RootLayout({ children }) {
return (
<html>
<body>
{children}
<Analytics />
</body>
</html>
);
}/**
* Performance Monitoring Template
* Complete implementation for tracking Core Web Vitals + custom metrics
*
* Features:
* - Core Web Vitals (LCP, INP, CLS) collection
* - Custom async operation timing
* - Render performance measurement
* - Batched reporting with sampling
* - Device and connection metadata
* - Development debugging mode
*/
import {
onCLS,
onINP,
onLCP,
onFCP,
onTTFB,
type Metric,
type ReportOpts,
} from 'web-vitals';
// ============================================
// Types
// ============================================
export interface PerformanceMetric {
name: string;
value: number;
rating?: 'good' | 'needs-improvement' | 'poor';
delta?: number;
id?: string;
navigationType?: string;
metadata?: Record<string, string | number | boolean>;
}
export interface ReporterConfig {
/** API endpoint to send metrics */
endpoint: string;
/** Sample rate 0-1, default 1 (100%) */
sampleRate?: number;
/** Always sample poor metrics regardless of rate */
alwaysSamplePoor?: boolean;
/** Enable console logging for debugging */
debug?: boolean;
/** Batch metrics before sending */
batchSize?: number;
/** Max time to hold batch before sending (ms) */
batchTimeout?: number;
/** Custom headers for requests */
headers?: Record<string, string>;
/** Page-specific sample rate multipliers */
pageMultipliers?: Record<string, number>;
}
interface DeviceInfo {
userAgent: string;
url: string;
referrer: string;
screenWidth: number;
screenHeight: number;
devicePixelRatio: number;
connectionType?: string;
effectiveType?: string;
downlink?: number;
rtt?: number;
deviceMemory?: number;
hardwareConcurrency?: number;
timestamp: number;
}
// ============================================
// Device & Connection Info
// ============================================
function getDeviceInfo(): DeviceInfo {
const nav = navigator as Navigator & {
connection?: {
type?: string;
effectiveType?: string;
downlink?: number;
rtt?: number;
};
deviceMemory?: number;
};
return {
userAgent: navigator.userAgent,
url: window.location.href,
referrer: document.referrer,
screenWidth: window.screen.width,
screenHeight: window.screen.height,
devicePixelRatio: window.devicePixelRatio,
connectionType: nav.connection?.type,
effectiveType: nav.connection?.effectiveType,
downlink: nav.connection?.downlink,
rtt: nav.connection?.rtt,
deviceMemory: nav.deviceMemory,
hardwareConcurrency: navigator.hardwareConcurrency,
timestamp: Date.now(),
};
}
// ============================================
// Performance Reporter Class
// ============================================
export class PerformanceReporter {
private config: Required<ReporterConfig>;
private queue: PerformanceMetric[] = [];
private flushTimeout: ReturnType<typeof setTimeout> | null = null;
private deviceInfo: DeviceInfo | null = null;
constructor(config: ReporterConfig) {
this.config = {
sampleRate: 1,
alwaysSamplePoor: true,
debug: false,
batchSize: 10,
batchTimeout: 5000,
headers: {},
pageMultipliers: {},
...config,
};
// Setup unload handler
if (typeof window !== 'undefined') {
window.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
this.flush();
}
});
// Capture device info once
this.deviceInfo = getDeviceInfo();
}
}
/**
* Report a performance metric
*/
report(metric: PerformanceMetric): void {
// Sampling logic
if (!this.shouldSample(metric)) {
return;
}
// Debug logging
if (this.config.debug) {
this.logMetric(metric);
}
// Enrich with device info and timestamp
const enrichedMetric: PerformanceMetric = {
...metric,
metadata: {
...metric.metadata,
...this.deviceInfo,
reportedAt: Date.now(),
},
};
this.queue.push(enrichedMetric);
// Flush if batch size reached
if (this.queue.length >= this.config.batchSize) {
this.flush();
} else {
this.scheduleFlush();
}
}
/**
* Determine if metric should be sampled
*/
private shouldSample(metric: PerformanceMetric): boolean {
// Always sample poor metrics if configured
if (this.config.alwaysSamplePoor && metric.rating === 'poor') {
return true;
}
// Check page-specific multiplier
const path = typeof window !== 'undefined' ? window.location.pathname : '/';
const multiplier = this.config.pageMultipliers[path] ?? 1;
const effectiveRate = this.config.sampleRate * multiplier;
return Math.random() < effectiveRate;
}
/**
* Schedule a flush after timeout
*/
private scheduleFlush(): void {
if (this.flushTimeout) return;
this.flushTimeout = setTimeout(() => {
this.flush();
this.flushTimeout = null;
}, this.config.batchTimeout);
}
/**
* Send queued metrics to the endpoint
*/
flush(): void {
if (this.queue.length === 0) return;
const metrics = [...this.queue];
this.queue = [];
if (this.flushTimeout) {
clearTimeout(this.flushTimeout);
this.flushTimeout = null;
}
const payload = JSON.stringify({
metrics,
batchTimestamp: Date.now(),
});
// Use sendBeacon for reliability during page unload
if (navigator.sendBeacon) {
const blob = new Blob([payload], { type: 'application/json' });
navigator.sendBeacon(this.config.endpoint, blob);
} else {
// Fallback to fetch with keepalive
fetch(this.config.endpoint, {
method: 'POST',
body: payload,
headers: {
'Content-Type': 'application/json',
...this.config.headers,
},
keepalive: true,
}).catch((error) => {
if (this.config.debug) {
console.error('[Performance Reporter] Failed to send metrics:', error);
}
});
}
}
/**
* Debug logging with color-coded output
*/
private logMetric(metric: PerformanceMetric): void {
const colors = {
good: 'color: green; font-weight: bold',
'needs-improvement': 'color: orange; font-weight: bold',
poor: 'color: red; font-weight: bold',
};
const color = metric.rating ? colors[metric.rating] : 'color: gray';
const unit = metric.name === 'CLS' ? '' : 'ms';
console.log(
`%c[${metric.name}] ${metric.value.toFixed(2)}${unit}`,
color,
metric.rating ? `(${metric.rating})` : '',
metric.metadata ?? ''
);
}
}
// ============================================
// Web Vitals Integration
// ============================================
/**
* Initialize Core Web Vitals monitoring
*/
export function initWebVitals(
reporter: PerformanceReporter,
opts?: ReportOpts
): void {
const reportWebVital = (metric: Metric) => {
reporter.report({
name: metric.name,
value: metric.value,
rating: metric.rating,
delta: metric.delta,
id: metric.id,
navigationType: metric.navigationType,
});
};
// Core Web Vitals (affect SEO)
onLCP(reportWebVital, opts);
onINP(reportWebVital, opts);
onCLS(reportWebVital, opts);
// Additional useful metrics
onFCP(reportWebVital, opts);
onTTFB(reportWebVital, opts);
}
// ============================================
// Custom Measurement Utilities
// ============================================
/**
* Measure an async operation's duration
*
* @example
* const data = await measureAsync(
* 'api-fetch-users',
* () => fetch('/api/users').then(r => r.json()),
* reporter
* );
*/
export async function measureAsync<T>(
name: string,
fn: () => Promise<T>,
reporter: PerformanceReporter,
metadata?: Record<string, string | number | boolean>
): Promise<T> {
const start = performance.now();
const startMark = `${name}-start`;
const endMark = `${name}-end`;
performance.mark(startMark);
try {
const result = await fn();
const duration = performance.now() - start;
performance.mark(endMark);
performance.measure(name, startMark, endMark);
reporter.report({
name,
value: duration,
metadata: {
type: 'async-operation',
success: true,
...metadata,
},
});
return result;
} catch (error) {
const duration = performance.now() - start;
reporter.report({
name,
value: duration,
metadata: {
type: 'async-operation',
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
...metadata,
},
});
throw error;
} finally {
// Cleanup marks
performance.clearMarks(startMark);
performance.clearMarks(endMark);
performance.clearMeasures(name);
}
}
/**
* Create a render timing measurement function
* Call the returned function when render completes
*
* @example
* function MyComponent() {
* const endRender = useMemo(
* () => measureRender('MyComponent', reporter),
* []
* );
*
* useEffect(() => {
* endRender();
* }, [endRender]);
* }
*/
export function measureRender(
name: string,
reporter: PerformanceReporter,
metadata?: Record<string, string | number | boolean>
): () => void {
const start = performance.now();
return () => {
const duration = performance.now() - start;
reporter.report({
name,
value: duration,
metadata: {
type: 'render',
...metadata,
},
});
};
}
/**
* Create a React hook for measuring component render time
*
* @example
* function ProductList({ products }) {
* useRenderMetric('ProductList', reporter, { itemCount: products.length });
* return <div>...</div>;
* }
*/
export function createRenderHook(reporter: PerformanceReporter) {
return function useRenderMetric(
name: string,
metadata?: Record<string, string | number | boolean>
): void {
// This would be implemented with useEffect in the actual React app
// Placeholder for demonstration
const start = performance.now();
// Simulate useEffect behavior
if (typeof window !== 'undefined') {
requestAnimationFrame(() => {
reporter.report({
name,
value: performance.now() - start,
metadata: {
type: 'render',
...metadata,
},
});
});
}
};
}
// ============================================
// Long Task Observer
// ============================================
/**
* Monitor long tasks (>50ms) that may impact INP
*/
export function observeLongTasks(
reporter: PerformanceReporter,
threshold = 50
): () => void {
if (!('PerformanceObserver' in window)) {
return () => {};
}
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.duration > threshold) {
reporter.report({
name: 'long-task',
value: entry.duration,
metadata: {
type: 'long-task',
startTime: entry.startTime,
// Attribution if available
...(entry as PerformanceEntry & { attribution?: unknown[] }).attribution
? { attribution: JSON.stringify((entry as any).attribution) }
: {},
},
});
}
}
});
try {
observer.observe({ type: 'longtask', buffered: true });
} catch {
// Long task observation not supported
}
return () => observer.disconnect();
}
// ============================================
// Layout Shift Observer
// ============================================
/**
* Monitor individual layout shifts for debugging CLS issues
*/
export function observeLayoutShifts(
reporter: PerformanceReporter,
minValue = 0.01
): () => void {
if (!('PerformanceObserver' in window)) {
return () => {};
}
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
const layoutShift = entry as PerformanceEntry & {
value: number;
hadRecentInput: boolean;
sources?: Array<{ node?: Element }>;
};
// Only report unexpected shifts (not from user input)
if (!layoutShift.hadRecentInput && layoutShift.value > minValue) {
reporter.report({
name: 'layout-shift',
value: layoutShift.value,
metadata: {
type: 'layout-shift',
startTime: entry.startTime,
hadRecentInput: layoutShift.hadRecentInput,
// Try to identify the shifting element
element: layoutShift.sources?.[0]?.node?.nodeName ?? 'unknown',
},
});
}
}
});
try {
observer.observe({ type: 'layout-shift', buffered: true });
} catch {
// Layout shift observation not supported
}
return () => observer.disconnect();
}
// ============================================
// Resource Timing
// ============================================
/**
* Monitor resource loading performance
*/
export function observeResources(
reporter: PerformanceReporter,
filter?: (entry: PerformanceResourceTiming) => boolean
): () => void {
if (!('PerformanceObserver' in window)) {
return () => {};
}
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
const resource = entry as PerformanceResourceTiming;
// Apply filter if provided
if (filter && !filter(resource)) {
continue;
}
reporter.report({
name: 'resource-timing',
value: resource.duration,
metadata: {
type: 'resource',
resourceType: resource.initiatorType,
name: resource.name,
transferSize: resource.transferSize,
encodedBodySize: resource.encodedBodySize,
decodedBodySize: resource.decodedBodySize,
// Timing breakdown
dns: resource.domainLookupEnd - resource.domainLookupStart,
tcp: resource.connectEnd - resource.connectStart,
ttfb: resource.responseStart - resource.requestStart,
download: resource.responseEnd - resource.responseStart,
},
});
}
});
try {
observer.observe({ type: 'resource', buffered: true });
} catch {
// Resource timing not supported
}
return () => observer.disconnect();
}
// ============================================
// LCP Element Detection
// ============================================
/**
* Identify the LCP element for debugging
*/
export function detectLCPElement(
callback: (element: Element | null, time: number) => void
): () => void {
if (!('PerformanceObserver' in window)) {
return () => {};
}
const observer = new PerformanceObserver((list) => {
const entries = list.getEntries();
const lastEntry = entries[entries.length - 1] as PerformanceEntry & {
element?: Element;
};
callback(lastEntry.element ?? null, lastEntry.startTime);
});
try {
observer.observe({ type: 'largest-contentful-paint', buffered: true });
} catch {
// LCP observation not supported
}
return () => observer.disconnect();
}
// ============================================
// Initialization Helper
// ============================================
/**
* Initialize complete performance monitoring
*
* @example
* const reporter = initPerformanceMonitoring({
* endpoint: '/api/vitals',
* sampleRate: 0.1, // 10% in production
* debug: process.env.NODE_ENV === 'development',
* pageMultipliers: {
* '/': 1.0, // Always sample homepage
* '/checkout': 1.0, // Always sample checkout
* },
* });
*
* // Measure custom operations
* await measureAsync('api-fetch', () => fetchData(), reporter);
*/
export function initPerformanceMonitoring(
config: ReporterConfig
): PerformanceReporter {
const reporter = new PerformanceReporter(config);
// Initialize Core Web Vitals
initWebVitals(reporter);
// Optionally enable additional observers in development
if (config.debug) {
observeLongTasks(reporter);
observeLayoutShifts(reporter);
// Log LCP element
detectLCPElement((element, time) => {
console.log('[LCP Element]', element, `at ${time.toFixed(0)}ms`);
});
}
return reporter;
}
// ============================================
// React Integration Example
// ============================================
/**
* Example React component for Web Vitals
* Copy and adapt to your app
*/
export const WebVitalsComponent = `
'use client';
import { useEffect, useRef } from 'react';
import { initPerformanceMonitoring, PerformanceReporter } from './performance-monitoring';
// Singleton reporter
let reporterInstance: PerformanceReporter | null = null;
export function getReporter(): PerformanceReporter {
if (!reporterInstance) {
reporterInstance = initPerformanceMonitoring({
endpoint: '/api/vitals',
sampleRate: process.env.NODE_ENV === 'production' ? 0.1 : 1,
debug: process.env.NODE_ENV === 'development',
alwaysSamplePoor: true,
pageMultipliers: {
'/': 1.0,
'/checkout': 1.0,
'/product/[id]': 0.5,
},
});
}
return reporterInstance;
}
export function WebVitals(): null {
const initialized = useRef(false);
useEffect(() => {
if (initialized.current) return;
initialized.current = true;
// Initialize monitoring
getReporter();
}, []);
return null;
}
// Usage in layout
// export default function RootLayout({ children }) {
// return (
// <html>
// <body>
// <WebVitals />
// {children}
// </body>
// </html>
// );
// }
`;
// ============================================
// Type Exports
// ============================================
export type { Metric, ReportOpts };