Setting Up Performance Budget for a Website
Performance budget is a set of limits on performance metrics: bundle size, load time, Core Web Vitals. CI/CD fails when budget is exceeded, preventing site degradation from version to version.
Key Metrics and Benchmarks
| Metric | Good | Needs Work | Poor |
|---|---|---|---|
| LCP (Largest Contentful Paint) | < 2.5s | 2.5–4s | > 4s |
| INP (Interaction to Next Paint) | < 200ms | 200–500ms | > 500ms |
| CLS (Cumulative Layout Shift) | < 0.1 | 0.1–0.25 | > 0.25 |
| TTFB (Time to First Byte) | < 600ms | 600ms–1.8s | > 1.8s |
| JS Bundle | < 200KB gzip | 200–500KB | > 500KB |
| Total Page Weight | < 1MB | 1–3MB | > 3MB |
Lighthouse CI: Budget by Metrics
// .lighthouserc.json
{
"ci": {
"collect": {
"url": [
"http://localhost:3000",
"http://localhost:3000/catalog",
"http://localhost:3000/checkout"
],
"numberOfRuns": 3,
"settings": {
"preset": "desktop",
"throttlingMethod": "simulate"
}
},
"assert": {
"assertions": {
"categories:performance": ["error", { "minScore": 0.85 }],
"categories:accessibility": ["error", { "minScore": 0.90 }],
"categories:seo": ["warn", { "minScore": 0.90 }],
"largest-contentful-paint": ["error", { "maxNumericValue": 2500 }],
"cumulative-layout-shift": ["error", { "maxNumericValue": 0.1 }],
"total-blocking-time": ["warn", { "maxNumericValue": 300 }],
"uses-optimized-images": ["warn"],
"unused-javascript": ["warn", { "maxNumericValue": 20000 }],
"render-blocking-resources": ["warn"]
}
},
"upload": {
"target": "temporary-public-storage"
}
}
}
Bundlesize: Budget on JS/CSS Size
// bundlesize.config.json
{
"files": [
{
"path": ".next/static/chunks/main-*.js",
"maxSize": "60 kB"
},
{
"path": ".next/static/chunks/pages/index-*.js",
"maxSize": "50 kB"
},
{
"path": ".next/static/chunks/pages/catalog-*.js",
"maxSize": "80 kB"
},
{
"path": ".next/static/css/*.css",
"maxSize": "30 kB"
}
]
}
npm install --save-dev bundlesize
# In package.json:
# "scripts": { "bundlesize": "bundlesize" }
Webpack Bundle Analyzer
// next.config.js
const withBundleAnalyzer = require('@next/bundle-analyzer')({
enabled: process.env.ANALYZE === 'true',
});
module.exports = withBundleAnalyzer({
// Next.js config
});
ANALYZE=true npm run build
# Opens interactive report in browser
GitHub Actions: Budget Check
# .github/workflows/performance-budget.yml
name: Performance Budget
on: [pull_request]
jobs:
lighthouse-ci:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20' }
- run: npm ci && 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: npx lhci autorun
env:
LHCI_GITHUB_APP_TOKEN: ${{ secrets.LHCI_GITHUB_APP_TOKEN }}
bundlesize:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20' }
- run: npm ci && npm run build
- name: Check bundle size
run: npm run bundlesize
env:
CI_REPO_OWNER: ${{ github.repository_owner }}
CI_REPO_NAME: ${{ github.event.repository.name }}
CI_PULL_REQUEST: ${{ github.event.pull_request.number }}
CI_COMMIT_SHA: ${{ github.sha }}
BUNDLESIZE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
Production Monitoring: SpeedCurve / DebugBear
For continuous Core Web Vitals monitoring in real traffic, use RUM (Real User Monitoring):
// Collect Web Vitals from real browser
import { onLCP, onINP, onCLS, onFCP, onTTFB } from 'web-vitals';
function sendToAnalytics(metric: any) {
fetch('/api/vitals', {
method: 'POST',
body: JSON.stringify({
name: metric.name,
value: metric.value,
id: metric.id,
page: window.location.pathname,
}),
headers: { 'Content-Type': 'application/json' },
});
}
onLCP(sendToAnalytics);
onINP(sendToAnalytics);
onCLS(sendToAnalytics);
-- P75 Core Web Vitals for last 24 hours
SELECT
name,
PERCENTILE_CONT(0.75) WITHIN GROUP (ORDER BY value) AS p75,
COUNT(*) AS samples
FROM web_vitals
WHERE created_at >= now() - interval '24 hours'
GROUP BY name;
Timeframe
Setting up performance budget in CI with Lighthouse CI and bundlesize, RUM monitoring: 2–3 business days.







