src/performance/webVitals.ts
import { onLCP, onINP, onCLS, Metric } from 'web-vitals';
const reportMetric = (metric: Metric) => {
// Send to analytics endpoint
// Use navigator.sendBeacon for reliability
const body = JSON.stringify({
name: metric.name,
value: metric.value,
rating: metric.rating,
delta: metric.delta,
id: metric.id,
navigationType: metric.navigationType,
// Include context for segmentation
deviceClass: navigator.hardwareConcurrency > 4 ? 'high' : 'low',
connection: (navigator as any).connection?.effectiveType || 'unknown',
});
if (navigator.sendBeacon) {
navigator.sendBeacon('/api/telemetry', body);
} else {
fetch('/api/telemetry', { body, method: 'POST', keepalive: true });
}
};
export const initWebVitals = () => {
// Sample rate management for high-traffic sites
const shouldReport = Math.random() < 0.1; // 10% sampling
if (!shouldReport) return;
onLCP(reportMetric);
onINP(reportMetric);
onCLS(reportMetric);
};
### 2. LCP Optimization Strategy
LCP is dominated by resource loading and rendering. The goal is to reduce the critical path length.
* **Resource Hints:** Use `fetchpriority="high"` for the LCP image. Avoid overusing `preload`, which can starve other critical resources.
* **Server Response:** Implement streaming SSR or Edge caching to reduce TTFB. A slow server response caps LCP regardless of client optimization.
* **Image Delivery:** Use modern formats (`avif`, `webp`) with `srcset` for resolution switching. Ensure the LCP image is not lazy-loaded.
```html
<!-- Optimal LCP Image Implementation -->
<img
src="/hero.avif"
srcset="/hero-800.avif 800w, /hero-1200.avif 1200w"
sizes="(max-width: 768px) 100vw, 50vw"
alt="Hero visual"
fetchpriority="high"
decoding="async"
width="1200"
height="600"
/>
3. INP Engineering
INP measures the latency of all interactions. It is sensitive to main thread blocking.
- Long Task Mitigation: Break up synchronous work. Use
scheduler.yield() or requestIdleCallback to yield to the browser before processing heavy computations.
- Web Workers: Offload non-DOM tasks (parsing, crypto, heavy calculations) to Web Workers.
- Hydration Strategy: For SPAs, use partial hydration or islands architecture to reduce initial JS execution. Only hydrate interactive components.
// src/utils/scheduleWork.ts
// Utility to yield to the main thread, improving INP
export const yieldToMain = () => {
return new Promise<void>((resolve) => {
if ('scheduler' in window && 'yield' in (window as any).scheduler) {
(window as any).scheduler.yield(resolve);
} else {
setTimeout(resolve, 0);
}
});
};
// Usage in a heavy processing loop
export async function processLargeDataset(data: any[]) {
const batchSize = 100;
for (let i = 0; i < data.length; i += batchSize) {
const batch = data.slice(i, i + batchSize);
// Process batch synchronously
batch.forEach(item => heavyTransform(item));
// Yield every batch to keep INP low
await yieldToMain();
}
}
4. CLS Stabilization
CLS is caused by unexpected layout shifts.
- Dimension Hints: Always set explicit
width and height or use CSS aspect-ratio for images and videos.
- Font Loading: Use
font-display: optional or swap with size-adjust to prevent layout shifts during font loading.
- Dynamic Content: Reserve space for ads and embeds. Avoid inserting content above existing elements unless triggered by user interaction.
/* CLS Prevention Patterns */
.container {
/* Reserve space for dynamic content */
min-height: 200px;
}
.image-wrapper {
/* Modern aspect-ratio support */
aspect-ratio: 16 / 9;
overflow: hidden;
}
img {
width: 100%;
height: 100%;
object-fit: cover;
}
/* Font stability */
@font-face {
font-family: 'SystemFont';
src: local('Arial');
size-adjust: 105%; /* Adjust to match target font metrics */
}
Pitfall Guide
1. The Preload Paradox
Mistake: Preloading every critical resource to boost LCP.
Explanation: preload consumes bandwidth and parser-blocking priority. Preloading multiple resources can delay the execution of critical JavaScript, increasing INP and delaying Time to Interactive.
Best Practice: Preload only the single most critical resource (usually the LCP image). Use modulepreload for JS only when necessary.
2. Lab vs. Field Discrepancy
Mistake: Relying solely on Lighthouse scores for deployment gates.
Explanation: Lighthouse runs on a throttled CPU but often underestimates main thread contention caused by third-party scripts or complex DOM interactions present in production.
Best Practice: Use Lighthouse for regression detection, but enforce RUM-based p75 thresholds for release criteria.
3. INP vs. TBT Confusion
Mistake: Assuming Total Blocking Time (TBT) correlates perfectly with INP.
Explanation: TBT measures blocking during load; INP measures blocking during interaction. A page can have low TBT but high INP if a specific interaction triggers a heavy render cycle or long task.
Best Practice: Profile specific interactions using the Performance tab. Look for interactionId in INP reports to identify which elements cause latency.
4. Third-Party Script Pollution
Mistake: Ignoring the impact of analytics, ads, and chat widgets.
Explanation: Third-party scripts often execute synchronously on the main thread, causing long tasks that degrade INP and increase LCP.
Best Practice: Load third-party scripts via tag managers with async or defer. Use Web Workers for analytics where possible. Implement script type="module" for better parsing behavior.
5. CLS from Font Fallback
Mistake: Using font-display: swap without metric adjustments.
Explanation: When the custom font loads, it may have different metrics than the fallback, causing a layout shift (CLS) even with swap.
Best Practice: Use size-adjust in @font-face to align fallback metrics with the custom font, or use font-display: optional for non-critical text.
6. Ignoring Mobile 3G/4G Variance
Mistake: Optimizing only for desktop or fast connections.
Explanation: Performance characteristics vary drastically by connection. An optimization that works on WiFi may fail on 3G due to latency.
Best Practice: Segment RUM data by effectiveType. Ensure critical paths are optimized for slow connections using progressive enhancement.
7. Reacting to Spikes Without Baselines
Mistake: Investigating every metric fluctuation.
Explanation: Web Vitals are distributions. A single spike may be noise.
Best Practice: Monitor p75 and p95 trends over time. Set alerts based on statistical significance, not absolute thresholds.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Marketing Site | Static/ISR with Preload | Content is static; LCP is priority. INP risk is low. | Low |
| E-commerce PDP | SSR + Hydration + Preload | High conversion sensitivity. Requires fast LCP and responsive INP. | Medium |
| SPA Dashboard | Islands/Partial Hydration | INP is critical for complex interactions. Reduce JS payload. | High |
| Blog/Content | Client-Side with Streaming | LCP less critical; focus on CLS and INP for readability. | Low |
Configuration Template
Next.js Performance Configuration:
// next.config.js
const nextConfig = {
reactStrictMode: true,
compress: true,
swcMinify: true,
images: {
formats: ['image/avif', 'image/webp'],
deviceSizes: [640, 750, 828, 1080, 1200],
imageSizes: [16, 32, 48, 64, 96],
minimumCacheTTL: 60,
},
// Reduce JS payload
webpack: (config, { isServer }) => {
if (!isServer) {
config.resolve.fallback = { fs: false, net: false, tls: false };
}
return config;
},
// Experimental: Optimize Fonts
experimental: {
optimizeFonts: true,
// Optimize external scripts
optimizeExternalScripts: true,
},
};
module.exports = nextConfig;
Vite Performance Configuration:
// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
build: {
rollupOptions: {
output: {
manualChunks: {
vendor: ['react', 'react-dom'],
utils: ['lodash', 'date-fns'],
},
},
},
target: 'es2020',
cssCodeSplit: true,
sourcemap: false,
},
optimizeDeps: {
include: ['react', 'react-dom'],
},
});
Quick Start Guide
- Install Library: Run
npm install web-vitals.
- Add Reporter: Create a
reportWebVitals.ts file using the code from the Core Solution. Import and call initWebVitals() in your app entry point.
- Verify Console: Open DevTools Console. You should see LCP, INP, and CLS values logged. Check the Network tab for telemetry requests.
- Baseline Audit: Run Lighthouse on your critical pages. Note the top opportunities.
- Fix Top 3: Address the highest impact issues: Add
fetchpriority to LCP image, remove render-blocking scripts, and fix CLS sources.
- Monitor: Check your analytics dashboard after 24 hours to verify p75 improvements in the field.