Web Performance Budgets and Core Web Vitals

How to implement web performance budgets. Covers LCP, FID, CLS optimization, bundle analysis, critical rendering path, and automated performance regression testing.

Web performance budgets and Core Web Vitals are the foundation for shipping fast, responsive, and reliable web experiences. They define measurable targets for key user-centric metrics—largely the three Core Web Vitals—and ensure that every change to the frontend stack, from code to assets to third-party scripts, remains within a defined performance envelope. This matters at every stage of development: during pull request reviews, before deployment, and in production monitoring. Without budgets, teams ship features that degrade user experience silently, with no clear signal when performance regresses.

Establishing a Performance Budget

A performance budget is a set of constraints—time, size, count, and latency—applied to the web page as a whole or to specific components. The most common approach uses Core Web Vitals as the metric backbone, with thresholds defined for Largest Contentful Paint (LCP), First Input Delay (FID), and Cumulative Layout Shift (CLS). These thresholds are typically:

These targets are encoded in a configuration file, usually budget.json or performance-budget.json, and checked during the build process.

{
  "budgets": [
    {
      "name": "Core Web Vitals",
      "metrics": {
        "LCP": 2500,
        "FID": 100,
        "CLS": 0.1
      },
      "thresholds": {
        "LCP": {
          "critical": 3000,
          "warning": 2750
        },
        "FID": {
          "critical": 200,
          "warning": 150
        },
        "CLS": {
          "critical": 0.25,
          "warning": 0.2
        }
      }
    },
    {
      "name": "Asset Budgets",
      "assets": [
        {
          "path": "/",
          "size": 1500000,
          "count": 15,
          "requests": 8
        },
        {
          "path": "/product/*",
          "size": 2000000,
          "count": 20
        }
      ]
    }
  ]
}

This configuration is used by a build tool or CI pipeline to evaluate the current state of the site. The tool reads the budget.json and compares it to actual metrics collected during a real browser run, typically from a Lighthouse or Puppeteer-based audit.

Running a Performance Budget Check

The most common workflow uses lighthouse CLI to generate audit reports and validate them against the budget.

npx lighthouse https://example.com --output=json --output=html --save-assets --config-path=lighthouse-config.json

The lighthouse-config.json file defines the audit settings:

{
  "settings": {
    "emulatedFormFactor": "desktop",
    "viewport": {
      "width": 1200,
      "height": 800
    },
    "throttling": {
      "method": "provided",
      "rTT": 400,
      "download": 1000,
      "upload": 500,
      "e2e": 1000
    },
    "onlyCategories": ["performance", "accessibility", "best-practices"]
  }
}

The output is a lighthouse-report.json file that includes full scores and raw metric values. This report is then fed into a budgeting tool, such as web-vitals-budget, which reads the budget.json and compares actual values to thresholds.

npx web-vitals-budget --budget-file=budget.json --report-file=lighthouse-report.json

The tool exits with status code 1 if any metric exceeds its critical threshold, and emits a failure message:

ERROR: Performance Budget Failed
- LCP: 3120ms (threshold: 2500ms) → critical
- FID: 142ms (threshold: 100ms) → warning
- CLS: 0.18 → critical
- Total asset size: 1.7MB (budget: 1.5MB) → warning

This output is used in CI pipelines to block merges, with the build failing if any critical budget metric is exceeded.

Enforcing Budgets in CI and CD

To ensure budgets are respected across development, a ci-budget.yml GitHub Actions workflow runs on every pull request.

name: Performance Budget Check
on: pull_request

jobs:
  budget:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 18
      - run: npm install
      - run: npm run build
      - run: npx lighthouse https://deploy-preview-42--example.netlify.app --output=json --output=html --save-assets --config-path=lighthouse-config.json
      - run: npx web-vitals-budget --budget-file=budget.json --report-file=lighthouse-report.json
        continue-on-error: true
      - uses: actions/upload-artifact@v3
        with:
          name: lighthouse-report
          path: lighthouse-report.html
      - run: |
          if [ $? -ne 0 ]; then
            echo "Performance budget failed. See lighthouse-report.html for details."
            exit 1
          fi

The continue-on-error: true allows the job to run even if the budget fails, but the final exit 1 ensures the PR is marked as failed. This triggers a review comment or checks list item with the budget summary.

Handling Budget Overruns

When a budget fails, it’s common to misinterpret the cause. The most frequent mistake is confusing FID with TTFB. FID measures the delay from first user input (e.g., click or tap) to when the browser can respond—typically the time between the first input event and the response of the event handler. It is sensitive to JavaScript execution, network latency, and render thread contention.

TTFB (Time to First Byte) is the time from the initial request to the first byte of the response, but it does not capture interactivity latency. A page can have a fast TTFB but still feel slow because of long JavaScript execution.

Another silent failure is CLS measurement during the first render. CLS is computed over the entire page lifecycle, but many teams measure it only up to LCP. The correct approach is to record layout shifts from the moment the page starts loading until the user has interacted with it, typically for at least 10 seconds. A shift that occurs after the first paint but before the first input is not captured if the measurement starts too late.

Optimizing for CLS

A common cause of high CLS is late-loading images and fonts. When the layout shifts after content is already visible, CLS spikes. The solution is to explicitly set width and height on <img> and <iframe> elements and use loading="lazy" with sizes and srcset.

<img
  src="hero.jpg"
  alt="Hero image"
  width="1200"
  height="675"
  loading="lazy"
  sizes="(max-width: 768px) 100vw, 50vw"
  srcset="hero-480w.jpg 480w, hero-800w.jpg 800w, hero-1200w.jpg 1200w"
/>

Additionally, fonts must be preloaded and declared with font-display: swap to avoid invisible text (FOIT). The CSS should declare font families with font-weight and font-style before the font is fully loaded.

@font-face {
  font-family: 'Inter';
  src: url('/fonts/inter.woff2') format('woff2');
  font-weight: 400;
  font-style: normal;
  font-display: swap;
}

body {
  font-family: 'Inter', sans-serif;
}

Handling FID in Practice

FID is sensitive to the timing and complexity of JavaScript execution. A common failure is not measuring FID after the initial paint. The metric starts at first paint, but if JavaScript is heavy and blocks the main thread, the first input event may arrive before the first paint, causing a delay that is not captured.

To fix this, ensure that the first input event is recorded from the moment the page is ready. This is done by setting up an event listener early in the index.html file, before any script is loaded.

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>My App</title>
  <link rel="preload" as="style" href="/styles.css" />
  <link rel="stylesheet" href="/styles.css" />
  <link rel="preload" as="script" href="/main.js" />
  <script>
    // Record first input before any JS loads
    const firstInput = performance.mark('first-input');
    const observer = new PerformanceObserver((list) => {
      for (const entry of list.getEntries()) {
        if (entry.entryType === 'first-input') {
          performance.measure('first-input-delay', 'first-input', entry.startTime);
          observer.disconnect();
          break;
        }
      }
    });
    observer.observe({ entryTypes: ['first-input'] });
  </script>
</head>
<body>
  <!-- Content -->
</body>
</html>

This script ensures that the first-input event is recorded precisely when the user first interacts with the page, even if the main JavaScript bundle is still loading.

Monitoring Performance in Production

Performance budgets are not just a CI gate—they are a living part of production observability. A production dashboard should display the current values of LCP, FID, and CLS for every page, updated in real time.

This is achieved by embedding the web-vitals JavaScript library in the frontend bundle.

<script type="module">
  import { getCLS, getFID, getLCP, getFCP, getTTFB } from 'https://cdn.jsdelivr.net/npm/[email protected]/dist/web-vitals.min.js';

  const reportWebVitals = (onPerfEntry) => {
    if (onPerfEntry && typeof onPerfEntry === 'function') {
      getCLS(onPerfEntry);
      getFID(onPerfEntry);
      getLCP(onPerfEntry);
      getFCP(onPerfEntry);
      getTTFB(onPerfEntry);
    }
  };

  reportWebVitals((metric) => {
    const body = JSON.stringify({
      metric: metric.name,
      value: metric.value,
      duration: metric.duration,
      entryType: metric.entryType,
      id: metric.id,
      navigationType: performance.getEntriesByType('navigation')[0].type
    });

    fetch('/api/performance', {
      method: 'POST',
      body,
      headers: {
        'Content-Type': 'application/json',
      },
    });
  });
</script>

This script collects all Core Web Vitals from the browser and sends them to a /api/performance endpoint. The server logs these metrics, allowing teams to track trends, detect regressions, and correlate performance with user behavior.

Dealing with Budget Drift

Over time, budgets drift due to new features, third-party scripts, and changing user demographics. A budget that was tight at launch becomes loose, and a once-acceptable FID becomes a bottleneck.

To combat drift, teams must define baseline values from a production release and compare every subsequent release against that baseline.

A typical workflow uses npm run compare-budgets <previous-build> <current-build>.

npm run compare-budgets -- \
  --previous=production-2024-03-15 \
  --current=preview-123 \
  --report=diff.html

This command fetches the Lighthouse reports from two builds and computes the delta in each metric. It also generates a diff report showing which assets grew, which scripts added latency, and where layout shifts occurred.

The output includes a summary like:

Budget Comparison: production-2024-03-15 → preview-123
- LCP: +310ms (from 2.1s → 2.41s)
- FID: +72ms (from 88ms → 160ms)
- CLS: +0.05 (from 0.08 → 0.13)
- Top 3 contributors to FID:
  1. analytics.js (38ms)
  2. cart-widget.js (22ms)
  3. user-tracking.js (18ms)

This report becomes the basis for performance reviews, and teams can decide whether to accept a new build or require optimization.

Common Pitfalls and Silent Failures

Summary

Performance budgets are not just a checklist—they are a shared language between developers, designers, and product managers. Core Web Vitals provide a common set of metrics to evaluate user experience. When combined with precise tooling and clear configuration, they ensure that every change to the frontend stack is evaluated against a concrete, measurable standard. The real power lies in the feedback loop: every commit is tested, every release is measured, and every performance regression is caught before users notice.

This page was rewritten on 18 October 2026. It replaced a templated version that was not specific to its own title. How these pages are written and checked. If anything here is wrong, tell us at [email protected] and we will correct it.