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, with patterns used at tanstackship.com.
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
# .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
{
"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
// 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
// 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
// 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 helps by keeping the base bundle small (~85 KB for a full-featured app), but the budget should cover everything: your code, your dependencies, your images, and your third-party scripts.
For a production SaaS with performance budgets enforced in CI, see tanstackship.com.
Top comments (0)