VOOZH about

URL: https://dev.to/feidou/performance-budgeting-setting-targets-and-automating-enforcement-33nl

⇱ Performance Budgeting: Setting Targets and Automating Enforcement - DEV Community


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.

Related Resources