DEV Community

sweet
sweet

Posted on

Performance Budgeting: Setting Targets and Automating Enforcement

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
  },
}
Enter fullscreen mode Exit fullscreen mode

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.'
            })
Enter fullscreen mode Exit fullscreen mode

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"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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")
}
Enter fullscreen mode Exit fullscreen mode

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
  }
)
Enter fullscreen mode Exit fullscreen mode

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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

Top comments (0)