TechLead
Lesson 19 of 22
5 min read
Performance Engineering

Performance Budgets

Set and enforce performance budgets for bundle size, load time, and Core Web Vitals to prevent regressions

What Are Performance Budgets?

A performance budget is a set of quantitative limits on metrics that affect user experience. Budgets transform performance from a vague aspiration into a measurable, enforceable standard. By setting budgets on bundle size, load time, and Core Web Vitals, teams can catch regressions before they reach production and make informed trade-off decisions.

Recommended Budget Targets

  • Total JavaScript: <300KB gzipped — Includes all first-party and third-party JS
  • Total page weight: <1.5MB — All resources including images, fonts, scripts
  • LCP: <2.5 seconds — Largest content visible within 2.5s on 4G
  • INP: <200ms — All interactions respond within 200ms
  • CLS: <0.1 — Minimal unexpected layout shifts
  • Time to Interactive: <3.8 seconds — Page fully interactive on mobile 4G

Implementing Budget Checks

// performance-budget.config.ts
interface PerformanceBudget {
  metric: string;
  budget: number;
  unit: string;
  severity: 'error' | 'warning';
}

export const budgets: PerformanceBudget[] = [
  // Bundle size budgets
  { metric: 'total-js-size', budget: 300, unit: 'KB (gzipped)', severity: 'error' },
  { metric: 'total-css-size', budget: 50, unit: 'KB (gzipped)', severity: 'warning' },
  { metric: 'largest-chunk', budget: 100, unit: 'KB (gzipped)', severity: 'error' },
  { metric: 'total-image-size', budget: 500, unit: 'KB', severity: 'warning' },

  // Lighthouse budgets
  { metric: 'performance-score', budget: 90, unit: 'score', severity: 'error' },
  { metric: 'lcp', budget: 2500, unit: 'ms', severity: 'error' },
  { metric: 'cls', budget: 0.1, unit: 'score', severity: 'error' },
  { metric: 'tbt', budget: 300, unit: 'ms', severity: 'warning' },
  { metric: 'fcp', budget: 1800, unit: 'ms', severity: 'warning' },

  // Request count budgets
  { metric: 'total-requests', budget: 50, unit: 'requests', severity: 'warning' },
  { metric: 'third-party-requests', budget: 10, unit: 'requests', severity: 'warning' },
];
// check-budgets.ts — CI script to enforce budgets
import * as fs from 'fs';
import * as path from 'path';
import * as zlib from 'zlib';

interface BudgetResult {
  metric: string;
  actual: number;
  budget: number;
  unit: string;
  passed: boolean;
  severity: string;
}

function checkBundleSizeBudgets(buildDir: string): BudgetResult[] {
  const results: BudgetResult[] = [];
  const staticDir = path.join(buildDir, '.next', 'static');

  // Calculate total JS size (gzipped)
  let totalJsSize = 0;
  let largestChunk = 0;

  function processDir(dir: string): void {
    if (!fs.existsSync(dir)) return;
    for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
      const fullPath = path.join(dir, entry.name);
      if (entry.isDirectory()) {
        processDir(fullPath);
      } else if (entry.name.endsWith('.js')) {
        const content = fs.readFileSync(fullPath);
        const gzipped = zlib.gzipSync(content);
        const sizeKB = gzipped.length / 1024;
        totalJsSize += sizeKB;
        largestChunk = Math.max(largestChunk, sizeKB);
      }
    }
  }

  processDir(staticDir);

  results.push({
    metric: 'total-js-size',
    actual: Math.round(totalJsSize),
    budget: 300,
    unit: 'KB (gzipped)',
    passed: totalJsSize <= 300,
    severity: 'error',
  });

  results.push({
    metric: 'largest-chunk',
    actual: Math.round(largestChunk),
    budget: 100,
    unit: 'KB (gzipped)',
    passed: largestChunk <= 100,
    severity: 'error',
  });

  return results;
}

// Run checks
const results = checkBundleSizeBudgets(process.cwd());
const errors = results.filter(r => !r.passed && r.severity === 'error');
const warnings = results.filter(r => !r.passed && r.severity === 'warning');

console.log('\nPerformance Budget Report:');
console.log('='.repeat(60));

for (const r of results) {
  const icon = r.passed ? 'PASS' : r.severity === 'error' ? 'FAIL' : 'WARN';
  console.log(`[${icon}] ${r.metric}: ${r.actual} / ${r.budget} ${r.unit}`);
}

if (errors.length > 0) {
  console.error(`\n${errors.length} budget(s) exceeded! Build failed.`);
  process.exit(1);
}
if (warnings.length > 0) {
  console.warn(`\n${warnings.length} budget warning(s).`);
}

GitHub Action for Budget Enforcement

// .github/workflows/perf-budget.yml
// name: Performance Budget Check
// on: [pull_request]
// jobs:
//   budget-check:
//     runs-on: ubuntu-latest
//     steps:
//       - uses: actions/checkout@v4
//       - uses: actions/setup-node@v4
//         with: { node-version: 20 }
//       - run: npm ci
//       - run: npm run build
//       - name: Check bundle budgets
//         run: npx tsx scripts/check-budgets.ts
//       - name: Run Lighthouse
//         uses: treosh/lighthouse-ci-action@v11
//         with:
//           configPath: './lighthouserc.js'
//           uploadArtifacts: true

// PR comment with budget diff
import { Octokit } from '@octokit/rest';

async function commentBudgetReport(
  results: BudgetResult[],
  prNumber: number
): Promise<void> {
  const octokit = new Octokit({ auth: process.env.GITHUB_TOKEN });

  const table = results.map(r => {
    const status = r.passed ? ':white_check_mark:' : ':x:';
    return `| ${status} | ${r.metric} | ${r.actual} | ${r.budget} | ${r.unit} |`;
  }).join('\n');

  const body = `## Performance Budget Report\n
| Status | Metric | Actual | Budget | Unit |
|--------|--------|--------|--------|------|
${table}
\n_Generated by Performance Budget CI_`;

  await octokit.issues.createComment({
    owner: 'your-org',
    repo: 'your-repo',
    issue_number: prNumber,
    body,
  });
}

Performance Budget Tips

  • Start from current baseline: Measure your current performance and set budgets slightly below
  • Enforce in CI: Fail builds that exceed error-level budgets
  • Track trends: Monitor budget metrics over time to catch gradual degradation
  • Review regularly: Tighten budgets as you optimize, loosen if justified by features
  • Make budgets visible: Post results on PR comments so the entire team sees them

Continue Learning