Performance Budgets in CI/CD: How to Set Them and Actually Enforce Them
A performance budget defines the maximum acceptable values for performance metrics on your application. A bundle size budget says "our main JavaScript bundle must stay under 200KB." A Core Web Vitals budget says "LCP must stay under 2.5s." Without enforcement, these are wishes. With CI integration, they're guardrails.
This guide covers setting budgets, enforcing them in CI, and the mistakes that make budgets fail in practice.
What to Set Budgets On
Performance budgets should cover three categories:
1. Resource Size Budgets
JavaScript is the most impactful — it's expensive to parse, compile, and execute. Set budgets on:
- Total JavaScript size (compressed)
- Individual bundle chunks
- CSS size
- Image sizes
- Total page weight
Why size budgets? Size correlates directly with load time, especially on slower networks and lower-end mobile devices. Size is also measurable at build time without running a browser.
2. Timing Budgets
Measurements from a real or simulated browser load:
- LCP (Largest Contentful Paint) — target: under 2.5s
- TBT (Total Blocking Time) — Lighthouse proxy for INP
- FCP (First Contentful Paint)
- Time to Interactive (TTI)
- Server response time (TTFB)
3. Quantity Budgets
Limits on the number of resources:
- Number of network requests
- Number of third-party scripts
- Number of render-blocking resources
Setting Realistic Budget Values
The biggest mistake: setting budgets at aspirational values rather than current values. A budget that immediately fails isn't enforced — it gets disabled.
Start from your current state:
# Get current Lighthouse metrics
npx lighthouse https://yourapp.com --output json | \
jq '.audits | {
lcp: .["largest-contentful-paint"].numericValue,
tbt: .["total-blocking-time"].numericValue,
fcp: .["first-contentful-paint"].numericValue,
cls: .["cumulative-layout-shift"].numericValue,
js_size: .["total-byte-weight"].details.items[] | select(.label == "Script") | .transferSize
}'Then set budgets at current value + 10% buffer to start. Tighten over time.
Example starting budgets for a typical React SaaS app:
// lighthouserc.js
module.exports = {
ci: {
assert: {
assertions: {
// Timing (from current measurements)
'largest-contentful-paint': ['error', { maxNumericValue: 3000 }], // current: 2.4s
'first-contentful-paint': ['warn', { maxNumericValue: 1500 }],
'total-blocking-time': ['warn', { maxNumericValue: 400 }],
'cumulative-layout-shift': ['error', { maxNumericValue: 0.15 }],
// Sizes
'uses-optimized-images': 'warn',
'unused-javascript': ['warn', { maxLength: 0 }], // flag any unused JS
},
},
},
};Bundle Size Budgets with webpack
If you use webpack, performance.hints and performance.maxAssetSize enforce bundle budgets at build time:
// webpack.config.js
module.exports = {
performance: {
hints: 'error', // 'warning' or 'error' — error fails the build
maxAssetSize: 250 * 1024, // 250KB per asset
maxEntrypointSize: 400 * 1024, // 400KB for entry points
assetFilter: function(assetFilename) {
// Only check JS and CSS
return assetFilename.endsWith('.js') || assetFilename.endsWith('.css');
},
},
};For more granular control, use bundlesize:
npm install --save-dev bundlesize// package.json
{
"bundlesize": [
{
"path": "./dist/main.*.js",
"maxSize": "200 kB",
"compression": "gzip"
},
{
"path": "./dist/vendor.*.js",
"maxSize": "150 kB",
"compression": "gzip"
},
{
"path": "./dist/*.css",
"maxSize": "50 kB",
"compression": "gzip"
}
]
}npx bundlesizeOutput:
PASS ./dist/main.abc123.js: 187.3 kB (gzip) < 200 kB
FAIL ./dist/vendor.def456.js: 162.1 kB (gzip) > 150 kBAdd to CI:
- name: Check bundle sizes
run: npx bundlesizeNext.js Bundle Analysis
Next.js has a built-in bundle analyzer via @next/bundle-analyzer:
npm install --save-dev @next/bundle-analyzer// next.config.js
const withBundleAnalyzer = require('@next/bundle-analyzer')({
enabled: process.env.ANALYZE === 'true',
});
module.exports = withBundleAnalyzer({
// your next config
});ANALYZE=true npm run buildThis opens a visual treemap of your bundle. Use it to identify which packages are taking up the most space before setting budgets.
For CI enforcement in Next.js, the bundlesize approach above works, or use Lighthouse CI targeting your built app.
A Complete CI Performance Gate
name: Performance Gates
on:
pull_request:
branches: [main]
paths:
- 'src/**'
- 'package.json'
- 'package-lock.json'
jobs:
bundle-size:
name: Bundle Size Check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- run: npm run build
- run: npx bundlesize
lighthouse:
name: Lighthouse Performance
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- run: npm run build
- name: Run Lighthouse CI
uses: treosh/lighthouse-ci-action@v10
with:
configPath: './lighthouserc.js'
temporaryPublicStorage: true
uploadArtifacts: true
env:
LHCI_GITHUB_APP_TOKEN: ${{ secrets.LHCI_GITHUB_APP_TOKEN }}The treosh/lighthouse-ci-action GitHub Action handles Lighthouse CI without needing to manage an LHCI server — it uses temporary public storage for report URLs.
Handling Third-Party Scripts
Third-party scripts (analytics, chat widgets, A/B testing tools) frequently blow up performance budgets. Strategies:
Exclude from synthetic tests:
// lighthouserc.js
collect: {
settings: {
blockedUrlPatterns: [
'*googletagmanager*',
'*segment.io*',
'*hotjar*',
]
}
}This tests your code's performance without third-party noise. Track third-party impact separately.
Budget for third-party impact separately: Use WebPageTest or real user monitoring to measure total page performance including third parties. Set a separate budget for "third-party JS weight."
Ratcheting Budgets
A static budget doesn't improve performance over time — it just prevents regression from the current state. A ratchet automatically tightens the budget as performance improves.
// ci/update-budget.js
import lighthouse from 'lighthouse';
import * as chromeLauncher from 'chrome-launcher';
import fs from 'fs';
const chrome = await chromeLauncher.launch({ chromeFlags: ['--headless'] });
const results = await lighthouse('https://yourapp.com', { port: chrome.port });
await chrome.kill();
const currentLCP = results.lhr.audits['largest-contentful-paint'].numericValue;
const budgetPath = 'lighthouserc.js';
// Read current budget, update if current performance is better
const content = fs.readFileSync(budgetPath, 'utf8');
const currentBudget = parseInt(content.match(/maxNumericValue: (\d+)/)[1]);
if (currentLCP * 1.1 < currentBudget) {
const updated = content.replace(
/maxNumericValue: \d+/,
`maxNumericValue: ${Math.round(currentLCP * 1.1)}`
);
fs.writeFileSync(budgetPath, updated);
console.log(`Updated LCP budget from ${currentBudget}ms to ${Math.round(currentLCP * 1.1)}ms`);
}Run this script periodically (weekly cron) to automatically tighten budgets as you improve performance.
Communicating Budget Status
Performance budgets create friction when developers don't understand them. Make budget status visible:
- PR comments: Lighthouse CI GitHub App posts scores automatically
- Status checks: bundle-size and Lighthouse CI appear as required status checks
- Slack notifications: webhook on budget failures
- name: Notify on failure
if: failure()
uses: rtCamp/action-slack-notify@v2
env:
SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }}
SLACK_MESSAGE: "Performance budget exceeded in PR #${{ github.event.pull_request.number }}"
SLACK_COLOR: dangerWhat Budgets Don't Cover
Performance budgets enforce build-time and synthetic test metrics. They don't monitor:
- Real user performance (Core Web Vitals field data from actual users)
- Application functionality (whether features actually work)
- Performance under load (concurrent users)
- Performance degradation over time (memory leaks, accumulation)
Performance budgets are a development-time guardrail. For production monitoring, HelpMeTest runs continuous tests against your live application — catching performance regressions and functional failures that don't show up until traffic hits the production environment.
The complete picture: bundle size budgets and Lighthouse CI catch regressions before deploy; HelpMeTest monitors your actual deployed application for both functional and performance issues in production.