TL;DR: A performance budget is a set of agreed-upon thresholds that your application must not exceed — bundle size, load time, image weight, API latency. Without a budget, performance degrades incrementally with every feature added. This guide covers how to define, measure, and enforce performance budgets in CI/CD.
Why Performance Budgets Matter
Performance decays silently. Each feature adds JavaScript, images, and API calls. Individually, each addition is unnoticeable. Cumulatively, over 6-12 months, a page that loaded in 1.5 seconds becomes a 4-second page.
| Feature | JS Added | Latency Added | Cumulative (6 months) |
|---|---|---|---|
| Analytics dashboard | 18 KB | +100ms | +100ms |
| Team collaboration | 25 KB | +200ms | +300ms |
| File upload UI | 12 KB | +80ms | +380ms |
| Data export | 8 KB | +50ms | +430ms |
| Chat integration | 35 KB | +300ms | +730ms |
Without a budget, the 730ms cumulative increase goes unnoticed until users complain — or your Core Web Vitals score drops and SEO rankings fall.
Defining Budgets
Metric Categories
// performance-budget.config.ts
export const performanceBudget = {
// Quantity budgets — tracked at build time
javascript: {
totalInitial: 150 * 1024, // 150 KB (gzipped)
routeLevel: 50 * 1024, // 50 KB per route chunk
},
css: {
totalInitial: 30 * 1024, // 30 KB
criticalPath: 15 * 1024, // 15 KB inlined
},
images: {
heroImage: 200 * 1024, // 200 KB
thumbnail: 50 * 1024, // 50 KB
totalPageWeight: 500 * 1024, // 500 KB total
maxImageCount: 15, // Max images per page
},
fonts: {
total: 40 * 1024, // 40 KB
maxCustomFonts: 2, // Max font families
},
// Timing budgets — tracked in CI and RUM
timing: {
lcp: 2500, // < 2.5s
cls: 0.1, // < 0.1
inp: 200, // < 200ms
ttfb: 800, // < 800ms
firstContentfulPaint: 1800, // < 1.8s
timeToInteractive: 3500, // < 3.5s
},
// Network budgets
network: {
totalRequests: 50, // Max HTTP requests
thirdPartyScripts: 5, // Max third-party scripts
blockingScripts: 0, // No render-blocking scripts
},
}
CI/CD Enforcement with Lighthouse CI
Integrating performance budgets into your CI pipeline ensures no PR can merge without meeting the defined thresholds. The following GitHub Actions workflow runs Lighthouse CI and bundle size checks on every pull request:
# .github/workflows/performance.yml
name: Performance Budget
on:
pull_request:
branches: [main]
jobs:
lighthouse:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- run: npm ci
- run: npm run build
- run: npm run start:preview &
- name: Run Lighthouse CI
run: |
npm install -g @lhci/cli
lhci autorun --config=lighthouserc.json
- name: Check Bundle Size
run: |
npm run build -- --report
node scripts/check-bundle-size.mjs ./dist/report.html
- name: Check Image Budget
run: |
node scripts/check-image-budget.mjs ./dist/public
- name: Comment PR on Failure
if: failure()
uses: actions/github-script@v6
with:
script: |
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: 'Performance budget check failed. See details in the workflow run.'
})
Lighthouse CI Configuration
Lighthouse CI assertions define what constitutes a pass or fail for each performance metric. This configuration enforces LCP, CLS, INP, and other Core Web Vitals thresholds, with varying severity levels for different metrics:
{
"ci": {
"collect": {
"numberOfRuns": 3,
"staticDistDir": "./dist/public",
"settings": {
"throttlingMethod": "devtools",
"formFactor": "desktop"
}
},
"assert": {
"assertions": {
"largest-contentful-paint": ["error", { "maxNumericValue": 2500 }],
"cumulative-layout-shift": ["error", { "maxNumericValue": 0.1 }],
"interaction-to-next-paint": ["error", { "maxNumericValue": 200 }],
"total-blocking-time": ["error", { "maxNumericValue": 200 }],
"max-potential-fid": ["warn", { "maxNumericValue": 100 }],
"uses-responsive-images": "error",
"offscreen-images": "error",
"unminified-javascript": "error",
"unminified-css": "error",
"render-blocking-resources": ["warn", { "maxLength": 3 }]
}
},
"upload": {
"target": "temporary-public-storage"
}
}
}
Bundle Size Enforcement
JavaScript bundle size is one of the most impactful performance levers you can control. The following script checks your built output against defined size budgets and fails the CI build if any bundle exceeds its limit:
// scripts/check-bundle-size.mjs
import { readFile } from "fs/promises"
import path from "path"
const BUDGET = {
"main": 150 * 1024, // Main entry point: 150KB
"vendor": 50 * 1024, // Vendor chunk: 50KB
}
async function checkBundleSize() {
const source = path.join(process.cwd(), "dist", "report.html")
const content = await readFile(source, "utf-8")
const failures = []
for (const [key, max] of Object.entries(BUDGET)) {
const size = extractBundleSize(content, key)
if (size && size > max) {
failures.push(`${key}: ${formatSize(size)} > ${formatSize(max)}`)
}
}
if (failures.length > 0) {
console.error("Bundle size budget failed:")
failures.forEach((f) => console.error(` ${f}`))
process.exit(1)
}
console.log("Bundle size budget passed")
}
Automated Alerts When Budget is Exceeded
Beyond CI enforcement, real-time monitoring of production performance data helps you catch regressions that only appear under real user conditions. This server function queries RUM data and build artifacts to generate performance alerts:
// server/monitoring/performance-alerts.ts
export const checkPerformanceAlert = createServerFn({ method: "GET" }).handler(
async ({}, { context }) => {
const results = []
// Check timing budgets from RUM data
const vitals = await context.env.DB.prepare(`
SELECT name, AVG(value) as avg, PERCENTILE(value, 95) as p95
FROM web_vitals
WHERE created_at > datetime('now', '-1 hour')
GROUP BY name
`).all()
const timingBudget = {
LCP: 2500,
CLS: 0.1,
INP: 200,
TTFB: 800,
}
for (const row of vitals.results) {
const threshold = timingBudget[row.name as keyof typeof timingBudget]
if (threshold && Number(row.p95) > threshold) {
results.push({
type: "performance_regression",
metric: row.name,
p95: row.p95,
threshold,
severity: "warning",
})
}
}
// Check bundle size from build artifacts
const bundleSize = await context.env.DB.prepare(`
SELECT route, SUM(size) as total
FROM bundle_sizes
WHERE version = (SELECT MAX(version) FROM bundle_sizes)
GROUP BY route
`).all()
const bundleBudget = 150 * 1024 // 150KB
const oversized = bundleSize.results.filter(
(r) => Number(r.total) > bundleBudget
)
if (oversized.length > 0) {
results.push({
type: "bundle_size_exceeded",
routes: oversized.map((r) => `${r.route}: ${formatSize(r.total)}`),
severity: "warning",
})
}
return results
}
)
Budget Visualization Dashboard
A real-time dashboard keeps your team aware of current performance status. This React component displays each metric against its budget threshold with color-coded status indicators:
// Dashboard component showing budget status
function PerformanceBudgetDashboard() {
const { data: budgets } = useQuery({
queryKey: ["performance-budget"],
queryFn: () => checkPerformanceAlert(),
refetchInterval: 60_000, // Refresh every minute
})
return (
<div className="grid grid-cols-3 gap-4">
{Object.entries(timingBudget).map(([metric, budget]) => (
<BudgetCard
key={metric}
metric={metric}
current={currentValues[metric]}
budget={budget}
/>
))}
</div>
)
}
function BudgetCard({ metric, current, budget }: BudgetCardProps) {
const ratio = current / budget
const status = ratio > 1 ? "failing" : ratio > 0.8 ? "warning" : "passing"
return (
<div className={`budget-card status-${status}`}>
<div className="metric-name">{metric}</div>
<div className="current-value">{formatMetric(metric, current)}</div>
<div className="budget-bar">
<div className="bar-fill" style={{ width: `${Math.min(ratio * 100, 100)}%` }} />
</div>
<div className="budget-limit">Limit: {formatMetric(metric, budget)}</div>
</div>
)
}
Budget Maintenance: When to Adjust
| Signal | Action |
|---|---|
| Budget consistently failing by < 10% | Reduce budget by 5% to match actual performance |
| Budget consistently failing by > 20% | Investigate the root cause — fix before adjusting budget |
| Budget passes with > 50% headroom | Tighten the budget — you can afford to be more aggressive |
| New feature requires budget increase | Increase budget, but set a date to optimize and reduce again |
| Core Web Vitals ranking drops | Investigate and fix — do not adjust budget to match |
Team Workflow
Developer opens PR
→ CI runs bundle analysis + Lighthouse
→ All budgets pass → Auto-merge enabled
→ Any budget fails → PR comment with specific failures
→ Developer reviews and optimizes
→ Or: acknowledges regression, adds performance-debt label
→ Performance review on Friday before deploy
Performance Budget Implementation Checklist
- Bundle size budgets defined for JS (initial), CSS, and per-route chunks
- Image budgets defined (total page weight, max count)
- Timing budgets defined (LCP, CLS, INP, TTFB)
- CI pipeline enforces budgets on every PR
- Bundle analyzer integrated into build output
- Lighthouse CI runs on every PR with assertion failures
- Performance budget dashboard in your monitoring tool
- Automated alerts when production budgets are exceeded
- Quarterly budget review and adjustment
- Performance regression label in GitHub for acknowledged regressions
Conclusion
A performance budget is not about perfection — it is about preventing silent degradation. By defining explicit thresholds and enforcing them in CI, you catch performance regressions before they reach production.
The key insight: every feature costs performance. Acknowledging that cost upfront — during code review rather than after user complaints — transforms performance from an afterthought to a first-class design constraint.
TanStack Start's small base bundle size (~85 KB) provides a strong starting point, but real performance requires ongoing discipline. For foundational metrics and optimization techniques, explore our Core Web Vitals Optimization Guide, Real User Monitoring for production measurement, Lighthouse 95+ Optimization Tips for audit best practices, and Edge Caching Strategies for CDN-layer performance gains.