Steve Kinney

Performance Budgets & Automation

Performance optimization without budgets is like dieting without a scale—you might feel like you’re making progress, but you have no idea if you’re actually succeeding. Performance budgets turn vague goals like “make it faster” into concrete, measurable targets like “keep the bundle under 200KB” and “ensure LCP stays below 2.5 seconds.” But budgets alone aren’t enough—you need automation to enforce them, catching performance regressions before they hit production.

This guide shows you how to establish realistic performance budgets for your React applications, implement automated testing that enforces these budgets in your CI/CD pipeline, and create a culture where performance is a feature, not an afterthought. You’ll learn to catch that 50KB library addition in PR review, prevent that O(n²) algorithm from reaching main, and sleep soundly knowing your app won’t suddenly become sluggish.

Understanding Performance Budgets

Performance budgets are constraints you set on various metrics that affect user experience:

// Types of performance budgets
interface PerformanceBudgets {
  // Bundle size budgets
  bundle: {
    mainJS: '200KB'; // Main bundle size
    mainCSS: '50KB'; // CSS bundle size
    totalInitial: '300KB'; // Total initial load
    chunkSize: '50KB'; // Max lazy-loaded chunk
    increase: '5%'; // Max increase per PR
  };

  // Loading performance budgets
  loading: {
    fcp: 1800; // First Contentful Paint (ms)
    lcp: 2500; // Largest Contentful Paint (ms)
    tti: 3800; // Time to Interactive (ms)
    fid: 100; // First Input Delay (ms)
    cls: 0.1; // Cumulative Layout Shift
  };

  // Runtime performance budgets
  runtime: {
    componentRender: 16; // Max render time (ms)
    stateUpdate: 100; // Max state update (ms)
    animation: 16; // Animation frame budget (ms)
    memoryIncrease: '10MB'; // Max memory growth per session
  };

  // Network budgets
  network: {
    requests: 50; // Max parallel requests
    apiResponse: 1000; // Max API response time (ms)
    totalTransfer: '1MB'; // Total network transfer
    cacheRatio: 0.8; // Min cache hit ratio
  };
}

Setting Realistic Budgets

Baseline Measurement

// utils/performanceBaseline.ts
export class PerformanceBaseliner {
  private metrics: Map<string, number[]> = new Map();

  async measureBaseline(url: string, iterations: number = 5) {
    const results = {
      bundle: await this.measureBundle(),
      loading: await this.measureLoading(url, iterations),
      runtime: await this.measureRuntime(url),
      recommendations: {} as any,
    };

    // Generate budget recommendations
    results.recommendations = this.generateRecommendations(results);

    return results;
  }

  private async measureBundle(): Promise<BundleMetrics> {
    // Use webpack-bundle-analyzer output
    const statsFile = await fetch('/bundle-stats.json');
    const stats = await statsFile.json();

    return {
      mainJS: stats.assets.find((a: any) => a.name.includes('main')).size,
      mainCSS: stats.assets.find((a: any) => a.name.includes('css')).size,
      totalInitial: stats.assets
        .filter((a: any) => a.isInitial)
        .reduce((sum: number, a: any) => sum + a.size, 0),
      chunks: stats.chunks.map((c: any) => ({
        name: c.names[0],
        size: c.size,
      })),
    };
  }

  private async measureLoading(url: string, iterations: number) {
    const measurements = [];

    for (let i = 0; i < iterations; i++) {
      const result = await this.runLighthouse(url);
      measurements.push(result);
    }

    // Calculate percentiles
    return {
      fcp: this.percentile(
        measurements.map((m) => m.fcp),
        75,
      ),
      lcp: this.percentile(
        measurements.map((m) => m.lcp),
        75,
      ),
      tti: this.percentile(
        measurements.map((m) => m.tti),
        75,
      ),
      fid: this.percentile(
        measurements.map((m) => m.fid),
        75,
      ),
      cls: this.percentile(
        measurements.map((m) => m.cls),
        75,
      ),
    };
  }

  private async runLighthouse(url: string) {
    // Run Lighthouse programmatically
    const lighthouse = await import('lighthouse');
    const chrome = await import('chrome-launcher');

    const browser = await chrome.launch({ chromeFlags: ['--headless'] });
    const options = {
      logLevel: 'error',
      output: 'json',
      port: browser.port,
    };

    const runnerResult = await lighthouse.default(url, options);
    await browser.kill();

    const { audits } = runnerResult.lhr;

    return {
      fcp: audits['first-contentful-paint'].numericValue,
      lcp: audits['largest-contentful-paint'].numericValue,
      tti: audits['interactive'].numericValue,
      fid: audits['max-potential-fid'].numericValue,
      cls: audits['cumulative-layout-shift'].numericValue,
    };
  }

  private generateRecommendations(results: any) {
    const recommendations = {
      bundle: {},
      loading: {},
    };

    // Bundle recommendations (add 20% buffer)
    recommendations.bundle = {
      mainJS: Math.ceil(results.bundle.mainJS * 1.2),
      mainCSS: Math.ceil(results.bundle.mainCSS * 1.2),
      totalInitial: Math.ceil(results.bundle.totalInitial * 1.2),
    };

    // Loading recommendations (use 75th percentile)
    recommendations.loading = {
      fcp: Math.ceil(results.loading.fcp * 1.1),
      lcp: Math.ceil(results.loading.lcp * 1.1),
      tti: Math.ceil(results.loading.tti * 1.1),
      fid: Math.min(100, Math.ceil(results.loading.fid * 1.1)),
      cls: Math.min(0.1, results.loading.cls * 1.1),
    };

    return recommendations;
  }

  private percentile(values: number[], p: number): number {
    const sorted = [...values].sort((a, b) => a - b);
    const index = Math.ceil((p / 100) * sorted.length) - 1;
    return sorted[index];
  }
}

// Generate baseline for your app
async function generateBaseline() {
  const baseliner = new PerformanceBaseliner();
  const baseline = await baseliner.measureBaseline('http://localhost:3000');

  console.log('Current Performance Baseline:');
  console.table(baseline);

  console.log('\nRecommended Budgets:');
  console.table(baseline.recommendations);

  // Save to config file
  fs.writeFileSync('performance-budget.json', JSON.stringify(baseline.recommendations, null, 2));
}

Webpack Bundle Analysis and Budgets

Webpack Configuration

// webpack.config.js
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
const CompressionPlugin = require('compression-webpack-plugin');
const { BundleBudgetPlugin } = require('./webpack/bundleBudgetPlugin');

module.exports = {
  plugins: [
    // Analyze bundle composition
    new BundleAnalyzerPlugin({
      analyzerMode: process.env.ANALYZE ? 'server' : 'disabled',
      generateStatsFile: true,
      statsFilename: 'bundle-stats.json',
    }),

    // Custom budget enforcement
    new BundleBudgetPlugin({
      bundles: [
        {
          name: 'main',
          maxSize: '200KB',
        },
        {
          name: 'vendor',
          maxSize: '150KB',
        },
      ],
      chunks: {
        maxSize: '50KB',
      },
    }),

    // Compression for production
    new CompressionPlugin({
      test: /\.(js|css|html|svg)$/,
      threshold: 8192,
      minRatio: 0.8,
    }),
  ],

  performance: {
    maxEntrypointSize: 300000, // 300KB
    maxAssetSize: 250000, // 250KB
    hints: process.env.NODE_ENV === 'production' ? 'error' : 'warning',
  },
};

Custom Budget Plugin

// webpack/bundleBudgetPlugin.js
const chalk = require('chalk');
const filesize = require('filesize');

class BundleBudgetPlugin {
  constructor(options) {
    this.budgets = options.bundles || [];
    this.chunkBudget = options.chunks || {};
    this.failOnError = options.failOnError !== false;
  }

  apply(compiler) {
    compiler.hooks.done.tap('BundleBudgetPlugin', (stats) => {
      const compilation = stats.compilation;
      const assets = compilation.assets;
      const errors = [];
      const warnings = [];

      // Check bundle budgets
      this.budgets.forEach((budget) => {
        const asset = Object.keys(assets).find((name) => name.includes(budget.name));

        if (asset) {
          const size = assets[asset].size();
          const maxSize = this.parseSize(budget.maxSize);

          if (size > maxSize) {
            const message = `Bundle "${budget.name}" exceeds budget: ${filesize(size)} > ${budget.maxSize}`;

            if (this.failOnError) {
              errors.push(new Error(chalk.red(message)));
            } else {
              warnings.push(chalk.yellow(message));
            }
          } else {
            console.log(
              chalk.green(`✓ Bundle "${budget.name}": ${filesize(size)} < ${budget.maxSize}`),
            );
          }
        }
      });

      // Check chunk budgets
      if (this.chunkBudget.maxSize) {
        const maxChunkSize = this.parseSize(this.chunkBudget.maxSize);

        compilation.chunks.forEach((chunk) => {
          if (!chunk.canBeInitial()) {
            const size = chunk.size();

            if (size > maxChunkSize) {
              const message = `Chunk "${chunk.name || chunk.id}" exceeds budget: ${filesize(size)} > ${this.chunkBudget.maxSize}`;

              if (this.failOnError) {
                errors.push(new Error(chalk.red(message)));
              } else {
                warnings.push(chalk.yellow(message));
              }
            }
          }
        });
      }

      // Add errors and warnings to compilation
      compilation.errors.push(...errors);
      compilation.warnings.push(...warnings);

      // Generate report
      this.generateReport(stats);
    });
  }

  parseSize(size) {
    if (typeof size === 'number') return size;

    const units = {
      KB: 1024,
      MB: 1024 * 1024,
      GB: 1024 * 1024 * 1024,
    };

    const match = size.match(/^(\d+(?:\.\d+)?)\s*(KB|MB|GB)$/i);
    if (!match) throw new Error(`Invalid size format: ${size}`);

    return parseFloat(match[1]) * units[match[2].toUpperCase()];
  }

  generateReport(stats) {
    const report = {
      timestamp: new Date().toISOString(),
      bundles: {},
      chunks: {},
      total: 0,
    };

    const assets = stats.compilation.assets;

    Object.keys(assets).forEach((name) => {
      const size = assets[name].size();
      report.bundles[name] = size;
      report.total += size;
    });

    // Save report for tracking
    fs.writeFileSync('performance-report.json', JSON.stringify(report, null, 2));
  }
}

module.exports = { BundleBudgetPlugin };

CI/CD Integration

GitHub Actions Workflow

# .github/workflows/performance.yml
name: Performance Budget Check

on:
  pull_request:
    branches: [main]

jobs:
  performance:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v3

      - name: Setup Node.js
        uses: actions/setup-node@v3
        with:
          node-version: '18'
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Build application
        run: npm run build

      - name: Check bundle size
        run: npm run budget:check

      - name: Run Lighthouse CI
        uses: treosh/lighthouse-ci-action@v10
        with:
          urls: |
            http://localhost:3000
            http://localhost:3000/dashboard
          budgetPath: './budget.json'
          temporaryPublicStorage: true

      - name: Upload performance artifacts
        uses: actions/upload-artifact@v3
        with:
          name: performance-results
          path: |
            .lighthouseci/
            performance-report.json
            bundle-stats.json

      - name: Comment PR with results
        uses: actions/github-script@v6
        if: github.event_name == 'pull_request'
        with:
          script: |
            const fs = require('fs');
            const report = JSON.parse(fs.readFileSync('performance-report.json'));

            const comment = `## Performance Report

            ### Bundle Sizes
            - Main: ${filesize(report.bundles.main)}
            - Vendor: ${filesize(report.bundles.vendor)}
            - Total: ${filesize(report.total)}

            ### Lighthouse Scores
            - Performance: ${report.lighthouse.performance}
            - FCP: ${report.lighthouse.fcp}ms
            - LCP: ${report.lighthouse.lcp}ms
            - TTI: ${report.lighthouse.tti}ms
            `;

            github.rest.issues.createComment({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
              body: comment
            });

Lighthouse CI Configuration

// lighthouserc.js
module.exports = {
  ci: {
    collect: {
      staticDistDir: './build',
      numberOfRuns: 3,
      settings: {
        preset: 'desktop',
        throttling: {
          cpuSlowdownMultiplier: 1,
        },
      },
    },
    assert: {
      preset: 'lighthouse:recommended',
      assertions: {
        'first-contentful-paint': ['error', { maxNumericValue: 1800 }],
        'largest-contentful-paint': ['error', { maxNumericValue: 2500 }],
        interactive: ['error', { maxNumericValue: 3800 }],
        'cumulative-layout-shift': ['error', { maxNumericValue: 0.1 }],
        'max-potential-fid': ['warn', { maxNumericValue: 100 }],
        'uses-responsive-images': 'warn',
        'uses-optimized-images': 'warn',
        'uses-text-compression': 'error',
        'uses-rel-preconnect': 'warn',
        'unused-javascript': ['warn', { maxNumericValue: 50000 }],
      },
    },
    upload: {
      target: 'temporary-public-storage',
    },
  },
};

Runtime Performance Monitoring

Runtime performance monitoring integrates with CI automation to enforce budgets during development. For detailed production monitoring implementation, see production-performance-monitoring.md.

// Basic budget violation detection for development
export function setupBudgetWatcher(budgets: RuntimeBudgets) {
  if (typeof window === 'undefined') return;

  const observer = new PerformanceObserver((list) => {
    for (const entry of list.getEntries()) {
      if (entry.entryType === 'measure' && entry.name.startsWith('react-render-')) {
        if (entry.duration > budgets.componentRender) {
          console.warn(
            `Budget violation: ${entry.name} took ${entry.duration.toFixed(2)}ms, budget: ${budgets.componentRender}ms`,
          );
        }
      }
    }
  });

  observer.observe({ entryTypes: ['measure'] });
  return () => observer.disconnect();
}

Automated Performance Testing

Performance testing should be integrated into your budget enforcement pipeline. For comprehensive testing strategies and patterns, see performance-testing-strategy.md.

Jest Performance Tests

// __tests__/performance.test.ts
import { render } from '@testing-library/react';
import { measureRender } from '../utils/performanceTest';

describe('Performance Tests', () => {
  describe('Component Render Performance', () => {
    it('should render ProductList within budget', async () => {
      const products = generateMockProducts(100);

      const { duration, rerenders } = await measureRender(
        <ProductList products={products} />
      );

      expect(duration).toBeLessThan(50); // 50ms budget
      expect(rerenders).toBe(0); // No unnecessary rerenders
    });

    it('should handle large datasets efficiently', async () => {
      const items = generateLargeDataset(10000);

      const { duration, memoryUsed } = await measureRender(
        <VirtualizedList items={items} />
      );

      expect(duration).toBeLessThan(100); // 100ms for large dataset
      expect(memoryUsed).toBeLessThan(50 * 1024 * 1024); // 50MB memory budget
    });
  });

  describe('State Update Performance', () => {
    it('should update state within budget', async () => {
      const { result } = renderHook(() => useComplexState());

      const startTime = performance.now();
      act(() => {
        result.current.updateMultipleFields({
          field1: 'value1',
          field2: 'value2',
          field3: 'value3',
        });
      });
      const duration = performance.now() - startTime;

      expect(duration).toBeLessThan(16); // One frame
    });
  });
});

// Performance test utilities
export async function measureRender(component: ReactElement) {
  const startTime = performance.now();
  const startMemory = performance.memory?.usedJSHeapSize || 0;

  let rerenders = 0;
  const { rerender } = render(
    <Profiler
      id="test"
      onRender={() => {
        rerenders++;
      }}
    >
      {component}
    </Profiler>
  );

  // Wait for effects
  await act(async () => {
    await new Promise(resolve => setTimeout(resolve, 0));
  });

  const duration = performance.now() - startTime;
  const memoryUsed = (performance.memory?.usedJSHeapSize || 0) - startMemory;

  return {
    duration,
    rerenders: rerenders - 1, // Subtract initial render
    memoryUsed,
  };
}

Playwright E2E Performance Tests

// e2e/performance.spec.ts
import { test, expect } from '@playwright/test';

test.describe('Performance E2E Tests', () => {
  test('should load homepage within performance budget', async ({ page }) => {
    // Start performance measurement
    await page.goto('/');

    // Collect performance metrics
    const metrics = await page.evaluate(() => {
      const navigation = performance.getEntriesByType(
        'navigation',
      )[0] as PerformanceNavigationTiming;
      const paint = performance.getEntriesByType('paint');

      return {
        domContentLoaded:
          navigation.domContentLoadedEventEnd - navigation.domContentLoadedEventStart,
        loadComplete: navigation.loadEventEnd - navigation.loadEventStart,
        fcp: paint.find((p) => p.name === 'first-contentful-paint')?.startTime,
        lcp: new Promise((resolve) => {
          new PerformanceObserver((list) => {
            const entries = list.getEntries();
            const lastEntry = entries[entries.length - 1];
            resolve(lastEntry.startTime);
          }).observe({ entryTypes: ['largest-contentful-paint'] });
        }),
      };
    });

    // Assert against budgets
    expect(metrics.domContentLoaded).toBeLessThan(1000);
    expect(metrics.loadComplete).toBeLessThan(3000);
    expect(metrics.fcp).toBeLessThan(1800);
    expect(await metrics.lcp).toBeLessThan(2500);
  });

  test('should handle interactions within budget', async ({ page }) => {
    await page.goto('/dashboard');

    // Measure interaction performance
    const interactionTime = await page.evaluate(async () => {
      const button = document.querySelector('[data-testid="filter-button"]');
      const startTime = performance.now();

      button?.click();

      // Wait for UI update
      await new Promise((resolve) => requestAnimationFrame(resolve));

      return performance.now() - startTime;
    });

    expect(interactionTime).toBeLessThan(100); // 100ms interaction budget
  });

  test('should not leak memory during navigation', async ({ page }) => {
    await page.goto('/');

    const initialMemory = await page.evaluate(() => {
      return performance.memory?.usedJSHeapSize || 0;
    });

    // Navigate through the app
    for (let i = 0; i < 10; i++) {
      await page.goto('/products');
      await page.goto('/dashboard');
      await page.goto('/settings');
    }

    await page.goto('/');

    // Force garbage collection if available
    await page.evaluate(() => {
      if (window.gc) window.gc();
    });

    const finalMemory = await page.evaluate(() => {
      return performance.memory?.usedJSHeapSize || 0;
    });

    const memoryIncrease = finalMemory - initialMemory;
    expect(memoryIncrease).toBeLessThan(10 * 1024 * 1024); // 10MB tolerance
  });
});

Bundle Size Tracking

Size Limit Configuration

// .size-limit.json
[
  {
    "name": "Main Bundle",
    "path": "build/static/js/main.*.js",
    "limit": "200 KB",
    "webpack": false,
    "gzip": true
  },
  {
    "name": "CSS Bundle",
    "path": "build/static/css/main.*.css",
    "limit": "50 KB",
    "webpack": false,
    "gzip": true
  },
  {
    "name": "Total App",
    "path": "build/static/**/*.{js,css}",
    "limit": "300 KB",
    "webpack": false,
    "gzip": true
  }
]

NPM Scripts

// package.json
{
  "scripts": {
    "build": "react-scripts build",
    "build:analyze": "ANALYZE=true npm run build",
    "budget:check": "size-limit",
    "budget:why": "size-limit --why",
    "performance:test": "jest --testMatch='**/*.perf.test.{ts,tsx}'",
    "lighthouse": "lighthouse http://localhost:3000 --budget-path=./budget.json --output=json --output-path=./lighthouse-report.json",
    "performance:baseline": "node scripts/generateBaseline.js",
    "performance:report": "node scripts/performanceReport.js"
  },
  "size-limit": [
    {
      "path": "build/static/js/*.js",
      "limit": "200 KB"
    }
  ],
  "husky": {
    "hooks": {
      "pre-push": "npm run budget:check"
    }
  }
}

Performance Dashboard Integration

Performance dashboards and alerting systems should be integrated with your automation pipeline to provide visibility into budget compliance over time. For comprehensive dashboard implementation and production alerting, see production-performance-monitoring.md.

Last modified on .