Performance BudgetWeb PerformanceCI/CDMonitoringSaaSLighthouse

Performance Budgeting: Setting Targets and Automating Enforcement

A practical guide to implementing performance budgets for SaaS applications — covering metric selection, budget thresholds, CI/CD enforcement, dashboard monitoring, and team workflows.

Alex Chen
Alex Chen
June 8, 202610 min read
Also available in:Deutsch · 中文

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.

FeatureJS AddedLatency AddedCumulative (6 months)
Analytics dashboard18 KB+100ms+100ms
Team collaboration25 KB+200ms+300ms
File upload UI12 KB+80ms+380ms
Data export8 KB+50ms+430ms
Chat integration35 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

typescript
// 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:

yaml
# .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:

json
{
  "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:

typescript
// 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:

typescript
// 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:

typescript
// 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

SignalAction
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% headroomTighten the budget — you can afford to be more aggressive
New feature requires budget increaseIncrease budget, but set a date to optimize and reduce again
Core Web Vitals ranking dropsInvestigate 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.