Performance Budgets
What Gets Measured Gets Managed
Every team says they care about performance. But without a budget — a concrete threshold that blocks a deploy when exceeded — performance death is just one PR at a time. Someone adds a date library (72KB). Another PR adds an animation library (45KB). A third adds a rich text editor loaded on every page (200KB). No single PR is the villain. The sum is.
A performance budget turns "we should keep this fast" into "this deploy is blocked until you shave 25KB." It transforms performance from a vague aspiration into an actual engineering constraint.
A performance budget works like a financial budget. You have a total balance (say, 200KB of JavaScript). Every feature costs something. Before adding a new feature, you check: does the budget allow it? If not, you either optimize existing code to make room, or you cut a less valuable feature. Without a budget, you just keep spending until the account is empty — and the user pays the debt as slow load times.
Three Types of Budgets
1. Size Budgets
The simplest and most enforceable — just cap the bytes:
| Asset | Budget | Rationale |
|---|---|---|
| Total JavaScript (compressed) | < 200KB | Fast 3G loads ~170KB/s. 200KB = ~1.2s download |
| Total CSS (compressed) | < 50KB | Render-blocking — every KB delays first paint |
| Individual route JS | < 80KB | Keep per-page cost low for code-split apps |
| Images (per page) | < 500KB | Largest contributor to page weight |
| Web fonts | < 100KB | Block text rendering until loaded |
| HTML document | < 30KB | First byte to first token |
// size-limit configuration (.size-limit.json)
[
{
"path": ".next/static/chunks/*.js",
"limit": "200 KB",
"gzip": true
},
{
"path": ".next/static/css/*.css",
"limit": "50 KB",
"gzip": true
},
{
"name": "Home page JS",
"path": ".next/static/chunks/pages/index-*.js",
"limit": "80 KB",
"gzip": true
}
]
2. Timing Budgets
Set thresholds for how long real interactions take:
| Metric | Budget | Why |
|---|---|---|
| LCP (Largest Contentful Paint) | < 2.5s | Google's "good" threshold |
| INP (Interaction to Next Paint) | < 200ms | User perceives delay above 200ms |
| CLS (Cumulative Layout Shift) | < 0.1 | Visible layout instability above this |
| Time to Interactive (TTI) | < 3.8s | Page usable within 4 seconds |
| First Contentful Paint (FCP) | < 1.8s | Something visible fast |
3. Rule-Based Budgets
Structural rules that prevent performance anti-patterns:
- No synchronous third-party scripts
- All images must have explicit
widthandheight - No CSS
@import(blocks rendering in a chain) - Fonts must use
font-display: swaporoptional - No uncompressed assets over 10KB
Setting Budgets from Real User Data
This is crucial: don't guess your budgets. Derive them from your actual users' devices and network conditions.
Step 1: Know your audience
Use analytics to determine the device and network profile of your P75 user (the user at the 75th percentile — worse than average, but not the worst case).
Real example from a SaaS product:
- P75 device: Samsung Galaxy A13 (mid-range, 2021)
- P75 network: 4G with ~8 Mbps effective bandwidth
- P75 CPU: ~3x slower than a MacBook Pro
Your MacBook on gigabit fiber is NOT your user.
Step 2: Work backward from timing goals
If your LCP goal is 2.5s on the P75 device/network:
Available time: 2500ms
- DNS + TCP + TLS: ~300ms (first visit)
- Server response (TTFB): ~400ms
- HTML download + parse: ~200ms
- CSS download + parse (render blocking): ~300ms
- Remaining for JS + render: ~1300ms
At 8 Mbps = 1MB/s:
- 1300ms = ~1.3MB of bandwidth
- But mid-range CPU needs ~3x parse/execute time vs desktop
- So JS budget: ~200KB compressed (700KB parsed, ~1s parse+execute on mid-range)
Step 3: Allocate across routes
Not every route needs the same budget. A marketing landing page should be lighter than a dashboard with charts:
Total JS budget: 200KB compressed
Shared framework (React + Next.js runtime): ~85KB
Shared UI components (design system): ~30KB
Remaining per-route budget: ~85KB
Landing page: 20KB route JS + 85KB shared = 105KB total
Dashboard: 80KB route JS + 85KB shared = 195KB total
Settings: 15KB route JS + 85KB shared = 100KB total
Enforcing Budgets in CI
A budget that isn't enforced is a suggestion — and suggestions get ignored. Wire budgets into your CI pipeline so they actually block merges.
size-limit
npm install --save-dev size-limit @size-limit/preset-app
// package.json
{
"size-limit": [
{ "path": ".next/static/**/*.js", "limit": "200 KB" }
],
"scripts": {
"size": "size-limit",
"size:check": "size-limit --why"
}
}
# GitHub Actions
- name: Check bundle size
run: npx size-limit
# Exits with code 1 if budget exceeded — blocks the PR
Lighthouse CI
// lighthouserc.js
module.exports = {
ci: {
assert: {
assertions: {
'largest-contentful-paint': ['error', { maxNumericValue: 2500 }],
'interactive': ['error', { maxNumericValue: 3800 }],
'cumulative-layout-shift': ['error', { maxNumericValue: 0.1 }],
'total-byte-weight': ['error', { maxNumericValue: 500000 }],
},
},
collect: {
url: ['http://localhost:3000/', 'http://localhost:3000/dashboard'],
numberOfRuns: 3,
},
},
};
bundlesize
// package.json
{
"bundlesize": [
{ "path": ".next/static/chunks/main-*.js", "maxSize": "80 KB" },
{ "path": ".next/static/chunks/framework-*.js", "maxSize": "50 KB" },
{ "path": ".next/static/css/*.css", "maxSize": "30 KB" }
]
}
Budget enforcement strategy
Start with warnings, not hard blocks. When introducing budgets to a team, run them in "warning mode" for 2-4 weeks. This reveals the current baseline and lets the team adjust. Once budgets are calibrated to realistic values (your current P90 is a good starting point), switch to blocking mode. Budget violations should be treated like failing tests — they block the PR until resolved. Over time, ratchet budgets down as the team optimizes.
Monitoring Regression Over Time
Budgets catch regressions at PR time, which is great. But what about the slow creep that no single PR triggers? You also need to track real-user performance over time to detect gradual drift and environmental changes (CDN issues, third-party script slowdowns).
Real User Monitoring (RUM)
// Report Core Web Vitals from real users
import { onLCP, onINP, onCLS } from 'web-vitals';
function reportMetric(metric) {
// Send to your analytics endpoint
fetch('/api/vitals', {
method: 'POST',
body: JSON.stringify({
name: metric.name,
value: metric.value,
rating: metric.rating, // 'good' | 'needs-improvement' | 'poor'
page: window.location.pathname,
connection: navigator.connection?.effectiveType,
deviceMemory: navigator.deviceMemory,
}),
keepalive: true, // survives page unload
});
}
onLCP(reportMetric);
onINP(reportMetric);
onCLS(reportMetric);
Track P75 and P90 values over time. Alert when metrics regress by more than 10% from the trailing 7-day average. This catches regressions that slip past CI — third-party script updates, CDN configuration changes, increased page complexity from content additions.
Budget Allocation Across Routes
Not all pages deserve the same budget. A marketing landing page must load fast to convert visitors. An admin settings page? The user is already committed — you've got more room to breathe.
Priority tiers:
Tier 1 (< 150KB JS): Landing page, pricing page, sign-up
→ First impression pages. Every 100ms delay = 7% conversion loss
Tier 2 (< 200KB JS): Dashboard, course viewer, search results
→ Core experience. Users expect responsiveness
Tier 3 (< 300KB JS): Settings, admin panel, analytics
→ Low-traffic, committed users. More complexity acceptable
Tier 4 (no strict budget): Internal tools, dev dashboards
→ Not user-facing. Optimize for developer productivity
The first TCP round trip delivers approximately 14KB (10 TCP packets × ~1.4KB each). If your critical HTML + inlined CSS fits within 14KB, the browser can start rendering after a single round trip. This is why inlining critical CSS and keeping initial HTML small has an outsized impact on FCP.
- 1A performance budget is a threshold that blocks deploys when exceeded — not a suggestion, a constraint.
- 2Three budget types: size (KB of JS/CSS), timing (LCP/INP/CLS thresholds), and rule-based (structural anti-pattern prevention).
- 3Derive budgets from your P75 user's real device and network — not your development machine.
- 4Enforce in CI with size-limit (deterministic byte checking) and Lighthouse CI (timing with multiple runs for stability).
- 5Allocate budgets per route: landing pages get tight budgets, admin pages get more room.
- 6Monitor real-user metrics (P75, P90) over time to catch gradual regression that per-PR checks miss.
- 7Ratchet budgets down after optimization sprints — never only up.
Q: Your team has never had performance budgets. The current home page ships 450KB of JavaScript (compressed). How do you introduce budgets without blocking every PR?
A strong answer: Start by measuring the current state — 450KB is the baseline. Set the initial budget at 460KB (slightly above current) so existing PRs aren't blocked. Enable "warning mode" in CI for 2 weeks. During this period, run an optimization sprint: audit dependencies (bundle analysis), remove unused code, lazy-load below-fold components, convert client components to server components where possible. Target getting to 300KB. Once there, set the budget at 320KB (10% headroom). Switch to blocking mode. Ratchet down every quarter. Simultaneously, set timing budgets using Lighthouse CI: LCP < 2.5s, INP < 200ms, CLS < 0.1.