Frontend Performance Budget Template
A template for defining JS, CSS, image, and font budgets per route — with a method for picking the numbers, CI enforcement, and production monitoring thresholds.
Overview
A performance budget sets limits on the size and timing of the resources that make up a page. Without one, page weight creeps up a dependency at a time until someone notices the site feels slow — usually a user, not a developer. This template gives you budgets per route, a CI gate that fails the build when you blow one, and monitoring thresholds for after it ships.
When to Use
- Starting a new web project
- Experiencing performance regressions
- Setting up Core Web Vitals monitoring
- Establishing team performance standards
- Preparing for performance-focused launches
Solution
# Performance Budget — `<Project Name>`
## Budget Overview
| Field | Value |
|-------|-------|
| Project | Example Web App |
| Last Updated | 2026-07-05 |
| Owner | Frontend Team |
| Enforcement | CI gate + Lighthouse CI |
| Monitoring | Real User Monitoring (RUM) + Synthetic |
## 1. Route-Level Budgets
### Home Page (`/`)
| Resource Type | Budget | Current | Status | Notes |
|---------------|--------|---------|--------|-------|
| Total JS (gzipped) | 150 KB | 142 KB | ✅ | Includes vendor bundle |
| Total CSS (gzipped) | 30 KB | 28 KB | ✅ | Tailwind + custom |
| Total images | 500 KB | 480 KB | ✅ | Hero + thumbnails |
| Total fonts | 100 KB | 80 KB | ✅ | 2 font families |
| HTML | 50 KB | 35 KB | ✅ | Server-rendered |
| Total page weight | 830 KB | 765 KB | ✅ | Sum of all resources |
| JS requests | 3 | 3 | ✅ | Vendor + app + lazy |
| CSS requests | 1 | 1 | ✅ | Single stylesheet |
| Image requests | 8 | 7 | ✅ | — |
| Font requests | 2 | 2 | ✅ | — |
| Total requests | 15 | 14 | ✅ | — |
### Product Page (`/products/:id`)
| Resource Type | Budget | Current | Status | Notes |
|---------------|--------|---------|--------|-------|
| Total JS (gzipped) | 180 KB | 165 KB | ✅ | Includes product gallery |
| Total CSS (gzipped) | 35 KB | 32 KB | ✅ | — |
| Total images | 800 KB | 750 KB | ✅ | Product images |
| Total fonts | 100 KB | 80 KB | ✅ | Same as home |
| HTML | 60 KB | 42 KB | ✅ | Product data |
| Total page weight | 1,175 KB | 1,069 KB | ✅ | — |
| JS requests | 4 | 4 | ✅ | Vendor + app + gallery + reviews |
| CSS requests | 1 | 1 | ✅ | — |
| Image requests | 12 | 10 | ✅ | Gallery + thumbnails |
| Font requests | 2 | 2 | ✅ | — |
| Total requests | 20 | 18 | ✅ | — |
### Checkout Page (`/checkout`)
| Resource Type | Budget | Current | Status | Notes |
|---------------|--------|---------|--------|-------|
| Total JS (gzipped) | 200 KB | 188 KB | ✅ | Includes payment SDK |
| Total CSS (gzipped) | 35 KB | 30 KB | ✅ | — |
| Total images | 100 KB | 50 KB | ✅ | Minimal images |
| Total fonts | 100 KB | 80 KB | ✅ | — |
| HTML | 40 KB | 30 KB | ✅ | — |
| Total page weight | 475 KB | 428 KB | ✅ | — |
| JS requests | 5 | 5 | ✅ | Vendor + app + payment + validation + analytics |
| CSS requests | 1 | 1 | ✅ | — |
| Image requests | 2 | 2 | ✅ | Logo + security badge |
| Font requests | 2 | 2 | ✅ | — |
| Total requests | 11 | 11 | ✅ | — |
### Dashboard Page (`/dashboard`)
| Resource Type | Budget | Current | Status | Notes |
|---------------|--------|---------|--------|-------|
| Total JS (gzipped) | 250 KB | 245 KB | ⚠️ | Chart library is heavy |
| Total CSS (gzipped) | 40 KB | 38 KB | ✅ | — |
| Total images | 200 KB | 150 KB | ✅ | Avatars + charts |
| Total fonts | 100 KB | 80 KB | ✅ | — |
| HTML | 80 KB | 65 KB | ✅ | Dashboard data |
| Total page weight | 670 KB | 578 KB | ✅ | — |
| JS requests | 6 | 6 | ✅ | Vendor + app + charts + date + table + auth |
| CSS requests | 1 | 1 | ✅ | — |
| Image requests | 5 | 4 | ✅ | — |
| Font requests | 2 | 2 | ✅ | — |
| Total requests | 15 | 14 | ✅ | — |
## 2. Core Web Vitals Targets
| Metric | Good | Needs Improvement | Poor | Target | Current (p75) | Status |
|--------|------|-------------------|------|--------|---------------|--------|
| LCP | < 2.5s | 2.5s - 4.0s | > 4.0s | < 2.5s | 2.1s | ✅ |
| INP | < 200ms | 200ms - 500ms | > 500ms | < 200ms | 180ms | ✅ |
| CLS | < 0.1 | 0.1 - 0.25 | > 0.25 | < 0.1 | 0.05 | ✅ |
| FCP | < 1.8s | 1.8s - 3.0s | > 3.0s | < 1.8s | 1.4s | ✅ |
| TTFB | < 800ms | 800ms - 1.8s | > 1.8s | < 800ms | 650ms | ✅ |
## 3. Timing Budgets
| Metric | Budget | Current | Status |
|--------|--------|---------|--------|
| Time to First Byte (TTFB) | < 800ms | 650ms | ✅ |
| First Contentful Paint (FCP) | < 1.8s | 1.4s | ✅ |
| Largest Contentful Paint (LCP) | < 2.5s | 2.1s | ✅ |
| Time to Interactive (TTI) | < 3.5s | 3.1s | ✅ |
| Total Blocking Time (TBT) | < 200ms | 150ms | ✅ |
| Cumulative Layout Shift (CLS) | < 0.1 | 0.05 | ✅ |
| Interaction to Next Paint (INP) | < 200ms | 180ms | ✅ |
## 4. Third-Party Budgets
| Third-Party | Type | JS Budget | Current | Status | Notes |
|-------------|------|-----------|---------|--------|-------|
| Google Analytics | Analytics | 45 KB | 42 KB | ✅ | Loaded async |
| Stripe.js | Payment | 50 KB | 48 KB | ✅ | Only on checkout |
| Sentry | Error tracking | 25 KB | 22 KB | ✅ | Loaded async |
| Google Maps | Maps | 80 KB | 75 KB | ✅ | Only on store locator |
| Intercom | Support | 60 KB | 55 KB | ✅ | Lazy loaded |
| **Total third-party** | | **260 KB** | **242 KB** | **✅** | |
## 5. CI Enforcement
### Bundle Size Check
```yaml
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 sizes
run: |
node scripts/check-bundle-size.js
- name: Lighthouse CI
run: |
npm install -g @lhci/cli
lhci collect --url=http://localhost:3000 --numberOfRuns=3
lhci assert --preset=lighthouse:recommended
env:
LHCI_GITHUB_APP_TOKEN: ${{ secrets.LHCI_TOKEN }}
```
### Bundle Size Script
```javascript
// scripts/check-bundle-size.js
const fs = require('fs');
const path = require('path');
const gzipSize = require('gzip-size');
const budgets = {
'dist/assets/vendor.js': { max: 100000, type: 'gzipped' },
'dist/assets/app.js': { max: 50000, type: 'gzipped' },
'dist/assets/styles.css': { max: 30000, type: 'gzipped' },
};
let failed = false;
for (const [file, budget] of Object.entries(budgets)) {
const filePath = path.join(process.cwd(), file);
if (!fs.existsSync(filePath)) {
console.error(`MISSING: ${file}`);
failed = true;
continue;
}
const content = fs.readFileSync(filePath);
const size = budget.type === 'gzipped' ? gzipSize.sync(content) : content.length;
const sizeKB = (size / 1024).toFixed(1);
const maxKB = (budget.max / 1024).toFixed(1);
if (size > budget.max) {
console.error(`FAIL: ${file} — ${sizeKB} KB (budget: ${maxKB} KB)`);
failed = true;
} else {
console.log(`OK: ${file} — ${sizeKB} KB (budget: ${maxKB} KB)`);
}
}
if (failed) {
process.exit(1);
}
```
### Lighthouse CI Config
```json
{
"ci": {
"collect": {
"url": [
"http://localhost:3000",
"http://localhost:3000/products/sample",
"http://localhost:3000/checkout"
],
"numberOfRuns": 3
},
"assert": {
"preset": "lighthouse:recommended",
"assertions": {
"categories:performance": ["warn", { "minScore": 0.9 }],
"largest-contentful-paint": ["error", { "maxNumericValue": 2500 }],
"cumulative-layout-shift": ["error", { "maxNumericValue": 0.1 }],
"total-blocking-time": ["warn", { "maxNumericValue": 200 }]
}
}
}
}
```
## 6. Production Monitoring
### RUM Thresholds
| Metric | Alert Threshold | Page | Action |
|--------|----------------|------|--------|
| LCP p75 > 3s | Warning | Any | Investigate slow routes |
| LCP p75 > 4s | Critical | Any | Roll back if recent deploy |
| INP p75 > 300ms | Warning | Any | Check for long tasks |
| CLS p75 > 0.15 | Warning | Any | Check for layout shifts |
| TTFB p75 > 1.2s | Warning | Any | Check server response |
| JS bundle size > budget | Warning | Any | Investigate bundle growth |
### Synthetic Monitoring
| Route | Check Frequency | Metrics | Alert |
|-------|----------------|---------|-------|
| / | Every 5 min | LCP, CLS, INP, TTFB | LCP > 3s |
| /products/sample | Every 10 min | LCP, CLS, INP | LCP > 3s |
| /checkout | Every 10 min | LCP, CLS, INP | LCP > 3s |
| /dashboard | Every 10 min | LCP, CLS, INP | LCP > 3s |
Explanation
Budgets work because the limit exists before the code does, and CI enforces it. The route-level breakdown matters: a checkout page carrying a payment SDK legitimately needs more JS than a blog post, so one global budget either strangles the heavy pages or goes slack everywhere else.
The Core Web Vitals targets come straight from Google’s published thresholds: LCP, INP, and CLS are the three metrics in the ranking signal. Hitting them buys you the user experience and the SEO side at once.
Third-party scripts are the most common source of budget overruns. Each third-party should have its own budget and be loaded only where needed. Stripe.js should only load on the checkout page, not globally. Analytics should load asynchronously to avoid blocking rendering.
CI enforcement is what makes a budget real instead of aspirational. The bundle check runs on every PR and fails the build if an asset exceeds its number; Lighthouse CI loads the built site and fails on CWV thresholds. Together they catch both “the file got bigger” and “the page got slower”.
Production monitoring catches what CI can’t see: CI tests on a clean runner, while your users are on real phones over real networks. RUM gives you the p75 reality, the same number Google uses for ranking — and synthetic checks give you a stable baseline to compare it against.
How to Set Your Numbers
The budget numbers in the template are illustrative; yours should come from measurement, not copying.
Step 1: Measure what you ship today. Run a build, gzip your bundles, and write down real sizes per route. Use Lighthouse or WebPageTest for the timing side, and pull RUM p75 if you’ve already got analytics. These are your baseline numbers, whether they’re good or embarrassing.
Step 2: Set budgets at baseline minus 0%, plus headroom. If your numbers are already acceptable, pin budgets at current size so nothing can regress. That’s how budgets earn their keep. If you’re already over, set the budget at current size anyway and force every new feature to pay for itself by removing something else; ratchet the budget down 5-10% each quarter as you clean up.
Step 3: Budget per route, not per site. A checkout page with a payment SDK legitimately carries more JS than a blog post. A single global budget either blocks the heavy routes or goes slack everywhere else. The template’s route tables exist precisely because “one number” doesn’t work.
Step 4: Budget the third-parties separately. They’ll account for a third to half your page weight, and they’re the assets you forget about until someone adds a chat widget to every page. Give each one its own line and its own budget — it makes the “do we really need this?” conversation concrete.
Step 5: Review quarterly. Budgets rot. A number that was tight in January becomes a lie by June: either the app legitimately grew or the budget was wrong. Put a calendar reminder on the template’s Last Updated field and treat the review like a dependency audit.
The loop is the point: CI stops regressions before they ship, RUM catches what CI’s clean environment can’t see, and the quarterly review keeps the numbers honest as the app evolves.
Real-World Budget Stories
Budgets feel abstract until you watch what happens without them — or with them enforced.
Pinterest’s rebuild (2018). After rebuilding their mobile web experience for performance, Pinterest’s engineering team reported a 40% lift in time on site and meaningful gains in signups from search traffic. The lesson they published was blunt: speed is the feature — it wasn’t a side effect of the redesign, it was the redesign. A budget is how you keep that win from evaporating six months later.
Walmart’s conversion math. The often-quoted figure — every 100 ms of load-time improvement correlated with roughly 1% conversion uplift — came out of Walmart’s own analysis. It’s the number that makes budgets legible to non-engineers: a budget line isn’t “200 KB of JS,” it’s “a measurable slice of revenue.”
HTTP Archive’s page-weight trend. Median desktop page weight has grown from under 1 MB a decade ago to well over 2 MB today — and mobile followed the same curve. Nothing about that growth is a decision anyone made; it’s a thousand small “just one more script” PRs. A budget is the tripwire that turns the creep into a conscious trade-off.
gov.uk’s published standard. The UK Government Service Standard requires teams to continuously measure performance and includes page weight in its performance criteria — a budget in all but name, enforced at the level of public services used by millions. If a government can hold the line on page weight, a product team has no excuse.
The pattern across all four: the teams that keep pages fast don’t have better discipline — they have a number, a gate, and a review cadence. That’s exactly the three parts of this template.
Variants
| Context | Approach | Notes |
|---|---|---|
| SPA | Budget per route (lazy loaded) | Code splitting is critical |
| SSR / SSG | Budget per page template | Server rendering reduces JS needs |
| Mobile-first | Stricter budgets (50% of desktop) | Mobile networks are slower |
| E-commerce | Stricter LCP (< 2s) | Revenue impact of performance |
| Content site | Focus on image budgets | Images are the heaviest assets |
| Enterprise app | Allow larger bundles | Internal users, controlled network |
What Works
- Set budgets per route; a single global budget is too restrictive
- Enforce in CI; catch regressions before production
- Monitor in production; CI can’t replicate real user conditions
- Track third-party scripts separately; they’re the most common overrun
- Use gzip sizes; raw sizes are misleading
- Review budgets quarterly; adjust as the app evolves
- Make budgets visible; dashboards showing budget status for each route
Common Mistakes
- No enforcement: budgets without CI gates are aspirational, not real
- Too generous: budgets that are never exceeded don’t prevent regressions
- Too strict: budgets that block every PR get bypassed or removed
- Ignoring third-party scripts: they can account for 50%+ of page weight
- Not monitoring production: CI passes but real users suffer
- Using raw sizes instead of gzipped: 200 KB raw might be 60 KB gzipped
- No route-level breakdown: a global 200 KB JS budget doesn’t account for route-specific needs
See Also
- Performance Budgets 101 — web.dev: Addy Osmani’s canonical guide to choosing and enforcing budgets
- Lighthouse CI documentation: setting up
@lhci/cliassertions like the config in this template - Core Web Vitals thresholds — web.dev: where the LCP/INP/CLS numbers in this template come from
- bundlesize and size-limit: lighter-weight alternatives to the custom check script
- Complete Guide to Bundle Size Optimization: the techniques that get you back under budget when a check fails
- Islands Architecture Pattern: the structural fix when the JS budget problem is hydration, not bundle size
Companion code: performance budget resources: the template, CI workflow, bundle-check script, and Lighthouse config from this page.
Frequently Asked Questions
How do I determine the right budget for my app?
Measure current performance first. Set budgets 10-20% above current sizes to allow normal development. If current sizes are already too large, set budgets at current sizes and require any new code to offset by removing old code. Lighthouse and WebPageTest are the tools for that measurement.
What if we can't meet the budget?
If you can't hit a budget, either the number is unrealistic or the code needs work. Start by analyzing the bundle with webpack-bundle-analyzer or source-map-explorer. Look for large dependencies that could be replaced, tree-shaken, or lazy-loaded. And if the number really is too low, raise it — just write down why.
Should we use Lighthouse CI or bundle size checks?
Both. Bundle size checks catch asset growth (a new dependency adds 50 KB). Lighthouse CI catches performance regressions that size checks miss (a new animation blocks the main thread). They measure different things, so you want both.
How do we handle third-party scripts that we can't control?
Async them, defer them, or hide them behind a tag manager. Set budgets for their impact on page weight and timing, and watch them in production too — if one keeps blowing its budget or dragging the page down, that's your cue to look for an alternative.
What is the difference between RUM and synthetic monitoring?
RUM (Real User Monitoring) collects data from the people actually visiting your site: real devices, real networks, real usage patterns. Synthetic monitoring runs scripted checks from clean, controlled machines. RUM shows the real experience; synthetic gives you consistent baselines to compare against. Use both.
Related Resources
Accessibility Audit Checklist
A WCAG 2.2 compliance checklist covering perceivable, operable, understandable, and robust criteria with testing tools and remediation steps.
DocComponent API Documentation Template
A template for documenting UI component APIs: props, events, slots, methods, accessibility, and usage examples with TypeScript types.
DocBrowser Support Matrix Template
A template for tracking browser support targets, feature compatibility, polyfill requirements, and fallback strategies across the browser matrix.
PatternIslands Architecture: Partial Hydration for Faster Pages
Server-render the page as static HTML and hydrate only the interactive islands. Covers Astro client directives, partial hydration, shared state, and when islands beat a SPA.
GuideBundle Size Optimization: A Practical Frontend Guide
A hands-on guide to measuring and reducing JavaScript bundle size with tree shaking, code splitting, dependency swaps, compression, and CI budgets.