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.
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.
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.
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.
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.
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;
}
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.
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.
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.
lcp entry is captured when PerformanceObserver sees a largest-contentful-paint entry, but the default observer may miss the first meaningful paint.--extra-headers and --emulated-form-factor to simulate real-world conditions.size and weight: A budget may specify a total asset size budget of 1.5MB, but this often includes both the raw bytes and the compressed (gzip) size. Teams must clarify whether the budget is for compressed size or raw size, and enforce it with gzip -c or brotli in the pipeline.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.
We use cookies for analytics (Google Analytics) and advertising (Google AdSense) to improve your experience and support free content. Privacy Policy