Web Performance Budgets and Core Web Vitals
How to implement web performance budgets. Covers LCP, FID, CLS optimization, bundle analysis, critical rendering path, and automated performance regression testing.
Web performance isn’t a feature — it’s a constraint. Every 100ms of latency costs 1% of revenue (Amazon). Every second of load time increases bounce rate by 7% (Google). Yet most teams treat performance as an afterthought, optimizing only when users complain. Performance budgets flip this: set hard limits upfront, enforce them in CI/CD, and performance becomes a non-negotiable quality bar.
Core Web Vitals
Google’s Core Web Vitals are the industry standard for measuring user experience:
| Metric | What It Measures | Good | Needs Work | Poor |
|---|---|---|---|---|
| LCP (Largest Contentful Paint) | Loading speed | ≤ 2.5s | ≤ 4.0s | > 4.0s |
| INP (Interaction to Next Paint) | Responsiveness | ≤ 200ms | ≤ 500ms | > 500ms |
| CLS (Cumulative Layout Shift) | Visual stability | ≤ 0.1 | ≤ 0.25 | > 0.25 |
LCP Optimization
The LCP element is usually a hero image, heading, or video poster. Optimize it with surgical precision:
<!-- Preload the LCP image -->
<link rel="preload" as="image" href="/hero.webp" fetchpriority="high">
<!-- Use responsive images -->
<img src="/hero.webp"
srcset="/hero-400.webp 400w, /hero-800.webp 800w, /hero-1200.webp 1200w"
sizes="(max-width: 768px) 100vw, 50vw"
loading="eager"
fetchpriority="high"
width="1200" height="630"
alt="Hero image">
LCP Checklist:
- Identify the LCP element (Chrome DevTools → Performance → LCP)
- Inline critical CSS for the LCP element
- Preload the LCP image with
fetchpriority="high" - Serve images in WebP/AVIF format
- Use a CDN for static assets
CLS Prevention
Layout shifts happen when elements load and push other content around. Every shift degrades user trust.
/* Always set dimensions for images and videos */
img, video {
width: 100%;
height: auto;
aspect-ratio: 16/9;
}
/* Reserve space for dynamic content */
.ad-slot {
min-height: 250px;
}
/* Prevent font swap flash */
@font-face {
font-family: 'Custom';
src: url('/font.woff2') format('woff2');
font-display: swap; /* or optional for better CLS */
size-adjust: 100.5%; /* Match fallback font metrics */
}
Performance Budgets
Define hard limits for every page:
| Metric | Budget | Enforcement |
|---|---|---|
| Total JS bundle | < 200KB (gzipped) | CI/CD gate |
| Total CSS | < 50KB (gzipped) | CI/CD gate |
| LCP | < 2.5s | Lighthouse CI |
| CLS | < 0.1 | Lighthouse CI |
| INP | < 200ms | Real User Monitoring |
| Hero image | < 100KB | Build step |
| Third-party scripts | < 3 | Manual review |
Automated Enforcement
# lighthouserc.json
{
"ci": {
"assert": {
"assertions": {
"categories:performance": ["error", { "minScore": 0.90 }],
"largest-contentful-paint": ["error", { "maxNumericValue": 2500 }],
"cumulative-layout-shift": ["error", { "maxNumericValue": 0.1 }],
"total-byte-weight": ["error", { "maxNumericValue": 500000 }]
}
}
}
}
Run in CI: npx lhci autorun — fails the build if any budget is exceeded.
Bundle Analysis
You can’t optimize what you can’t see. Run bundle analysis on every release:
# Next.js
npx @next/bundle-analyzer
# Webpack
npx webpack-bundle-analyzer dist/stats.json
# Vite
npx rollup-plugin-visualizer
Common bloat sources:
- Moment.js (330KB) → use date-fns (tree-shakeable) or Temporal API
- Lodash (full import) → use lodash-es with tree shaking
- Icon libraries (full set) → import only used icons
- Unused polyfills → check browser target and remove
Critical Rendering Path
The browser can’t render until it has parsed HTML, downloaded CSS, and executed render-blocking JS. Optimize the critical path:
- Inline critical CSS: Extract above-the-fold CSS and inline it in
<head> - Defer non-critical CSS: Load below-the-fold styles asynchronously
- Async/defer scripts: Never block rendering with JavaScript
<!-- Critical CSS inlined -->
<style>/* above-the-fold styles */</style>
<!-- Non-critical CSS loaded async -->
<link rel="preload" href="/styles.css" as="style" onload="this.onload=null;this.rel='stylesheet'">
<!-- Scripts with defer -->
<script src="/app.js" defer></script>
Real User Monitoring (RUM)
Lab metrics (Lighthouse) show potential. Field metrics (RUM) show reality. Implement RUM to track actual user experience:
// Web Vitals library
import { onLCP, onINP, onCLS } from 'web-vitals';
function sendToAnalytics(metric) {
navigator.sendBeacon('/api/vitals', JSON.stringify({
name: metric.name,
value: metric.value,
rating: metric.rating,
path: window.location.pathname,
connection: navigator.connection?.effectiveType,
}));
}
onLCP(sendToAnalytics);
onINP(sendToAnalytics);
onCLS(sendToAnalytics);
Track P75 (not average) — this represents the experience of your 75th percentile user, which Google uses for ranking decisions.