Performance Budgets in CI/CD: How to Set Them and Actually Enforce Them

Performance Budgets in CI/CD: How to Set Them and Actually Enforce Them

A performance budget defines the maximum acceptable values for performance metrics on your application. A bundle size budget says "our main JavaScript bundle must stay under 200KB." A Core Web Vitals budget says "LCP must stay under 2.5s." Without enforcement, these are wishes. With CI integration, they're guardrails.

This guide covers setting budgets, enforcing them in CI, and the mistakes that make budgets fail in practice.

What to Set Budgets On

Performance budgets should cover three categories:

1. Resource Size Budgets

JavaScript is the most impactful — it's expensive to parse, compile, and execute. Set budgets on:

  • Total JavaScript size (compressed)
  • Individual bundle chunks
  • CSS size
  • Image sizes
  • Total page weight

Why size budgets? Size correlates directly with load time, especially on slower networks and lower-end mobile devices. Size is also measurable at build time without running a browser.

2. Timing Budgets

Measurements from a real or simulated browser load:

  • LCP (Largest Contentful Paint) — target: under 2.5s
  • TBT (Total Blocking Time) — Lighthouse proxy for INP
  • FCP (First Contentful Paint)
  • Time to Interactive (TTI)
  • Server response time (TTFB)

3. Quantity Budgets

Limits on the number of resources:

  • Number of network requests
  • Number of third-party scripts
  • Number of render-blocking resources

Setting Realistic Budget Values

The biggest mistake: setting budgets at aspirational values rather than current values. A budget that immediately fails isn't enforced — it gets disabled.

Start from your current state:

# Get current Lighthouse metrics
npx lighthouse https://yourapp.com --output json | \
  jq '.audits | {
    lcp: .["largest-contentful-paint"].numericValue,
    tbt: .["total-blocking-time"].numericValue,
    fcp: .["first-contentful-paint"].numericValue,
    cls: .["cumulative-layout-shift"].numericValue,
    js_size: .["total-byte-weight"].details.items[] | select(.label == "Script") | .transferSize
  }'

Then set budgets at current value + 10% buffer to start. Tighten over time.

Example starting budgets for a typical React SaaS app:

// lighthouserc.js
module.exports = {
    ci: {
        assert: {
            assertions: {
                // Timing (from current measurements)
                'largest-contentful-paint': ['error', { maxNumericValue: 3000 }], // current: 2.4s
                'first-contentful-paint': ['warn', { maxNumericValue: 1500 }],
                'total-blocking-time': ['warn', { maxNumericValue: 400 }],
                'cumulative-layout-shift': ['error', { maxNumericValue: 0.15 }],
                
                // Sizes
                'uses-optimized-images': 'warn',
                'unused-javascript': ['warn', { maxLength: 0 }], // flag any unused JS
            },
        },
    },
};

Bundle Size Budgets with webpack

If you use webpack, performance.hints and performance.maxAssetSize enforce bundle budgets at build time:

// webpack.config.js
module.exports = {
    performance: {
        hints: 'error', // 'warning' or 'error' — error fails the build
        maxAssetSize: 250 * 1024, // 250KB per asset
        maxEntrypointSize: 400 * 1024, // 400KB for entry points
        assetFilter: function(assetFilename) {
            // Only check JS and CSS
            return assetFilename.endsWith('.js') || assetFilename.endsWith('.css');
        },
    },
};

For more granular control, use bundlesize:

npm install --save-dev bundlesize
// package.json
{
    "bundlesize": [
        {
            "path": "./dist/main.*.js",
            "maxSize": "200 kB",
            "compression": "gzip"
        },
        {
            "path": "./dist/vendor.*.js",
            "maxSize": "150 kB",
            "compression": "gzip"
        },
        {
            "path": "./dist/*.css",
            "maxSize": "50 kB",
            "compression": "gzip"
        }
    ]
}
npx bundlesize

Output:

PASS  ./dist/main.abc123.js: 187.3 kB (gzip) < 200 kB
FAIL  ./dist/vendor.def456.js: 162.1 kB (gzip) > 150 kB

Add to CI:

- name: Check bundle sizes
  run: npx bundlesize

Next.js Bundle Analysis

Next.js has a built-in bundle analyzer via @next/bundle-analyzer:

npm install --save-dev @next/bundle-analyzer
// next.config.js
const withBundleAnalyzer = require('@next/bundle-analyzer')({
    enabled: process.env.ANALYZE === 'true',
});

module.exports = withBundleAnalyzer({
    // your next config
});
ANALYZE=true npm run build

This opens a visual treemap of your bundle. Use it to identify which packages are taking up the most space before setting budgets.

For CI enforcement in Next.js, the bundlesize approach above works, or use Lighthouse CI targeting your built app.

A Complete CI Performance Gate

name: Performance Gates

on:
  pull_request:
    branches: [main]
    paths:
      - 'src/**'
      - 'package.json'
      - 'package-lock.json'

jobs:
  bundle-size:
    name: Bundle Size Check
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
      - run: npm ci
      - run: npm run build
      - run: npx bundlesize

  lighthouse:
    name: Lighthouse Performance
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
      - run: npm ci
      - run: npm run build
      - name: Run Lighthouse CI
        uses: treosh/lighthouse-ci-action@v10
        with:
          configPath: './lighthouserc.js'
          temporaryPublicStorage: true
          uploadArtifacts: true
        env:
          LHCI_GITHUB_APP_TOKEN: ${{ secrets.LHCI_GITHUB_APP_TOKEN }}

The treosh/lighthouse-ci-action GitHub Action handles Lighthouse CI without needing to manage an LHCI server — it uses temporary public storage for report URLs.

Handling Third-Party Scripts

Third-party scripts (analytics, chat widgets, A/B testing tools) frequently blow up performance budgets. Strategies:

Exclude from synthetic tests:

// lighthouserc.js
collect: {
    settings: {
        blockedUrlPatterns: [
            '*googletagmanager*',
            '*segment.io*',
            '*hotjar*',
        ]
    }
}

This tests your code's performance without third-party noise. Track third-party impact separately.

Budget for third-party impact separately: Use WebPageTest or real user monitoring to measure total page performance including third parties. Set a separate budget for "third-party JS weight."

Ratcheting Budgets

A static budget doesn't improve performance over time — it just prevents regression from the current state. A ratchet automatically tightens the budget as performance improves.

// ci/update-budget.js
import lighthouse from 'lighthouse';
import * as chromeLauncher from 'chrome-launcher';
import fs from 'fs';

const chrome = await chromeLauncher.launch({ chromeFlags: ['--headless'] });
const results = await lighthouse('https://yourapp.com', { port: chrome.port });
await chrome.kill();

const currentLCP = results.lhr.audits['largest-contentful-paint'].numericValue;
const budgetPath = 'lighthouserc.js';

// Read current budget, update if current performance is better
const content = fs.readFileSync(budgetPath, 'utf8');
const currentBudget = parseInt(content.match(/maxNumericValue: (\d+)/)[1]);

if (currentLCP * 1.1 < currentBudget) {
    const updated = content.replace(
        /maxNumericValue: \d+/,
        `maxNumericValue: ${Math.round(currentLCP * 1.1)}`
    );
    fs.writeFileSync(budgetPath, updated);
    console.log(`Updated LCP budget from ${currentBudget}ms to ${Math.round(currentLCP * 1.1)}ms`);
}

Run this script periodically (weekly cron) to automatically tighten budgets as you improve performance.

Communicating Budget Status

Performance budgets create friction when developers don't understand them. Make budget status visible:

  1. PR comments: Lighthouse CI GitHub App posts scores automatically
  2. Status checks: bundle-size and Lighthouse CI appear as required status checks
  3. Slack notifications: webhook on budget failures
- name: Notify on failure
  if: failure()
  uses: rtCamp/action-slack-notify@v2
  env:
    SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }}
    SLACK_MESSAGE: "Performance budget exceeded in PR #${{ github.event.pull_request.number }}"
    SLACK_COLOR: danger

What Budgets Don't Cover

Performance budgets enforce build-time and synthetic test metrics. They don't monitor:

  • Real user performance (Core Web Vitals field data from actual users)
  • Application functionality (whether features actually work)
  • Performance under load (concurrent users)
  • Performance degradation over time (memory leaks, accumulation)

Performance budgets are a development-time guardrail. For production monitoring, HelpMeTest runs continuous tests against your live application — catching performance regressions and functional failures that don't show up until traffic hits the production environment.

The complete picture: bundle size budgets and Lighthouse CI catch regressions before deploy; HelpMeTest monitors your actual deployed application for both functional and performance issues in production.

Read more

Start now free