1.5 — Performance budgets¶
Module 1 · Lesson 5 · 🟡 Intermediate · ~25 min
What you'll learn¶
- How to derive budgets from user experience targets instead of picking round numbers
- Per‑page‑type budgets for a commerce site, with the reasoning
- How to enforce budgets without making every PR a fight
- What to do when a budget legitimately needs to break
Why budgets, not goals¶
A goal is "make the PDP fast". A budget is "the PDP ships ≤ 260 KB of JavaScript, and the PR that exceeds it fails CI." Goals lose arguments with feature deadlines. Budgets convert an unbounded negotiation into a bounded one: what are you removing to make room?
Without budgets, commerce sites degrade at a predictable ~10–20% per year. Every quarter adds a tag, a widget, a personalization SDK, an experiment framework. Nobody is at fault for any single addition; the aggregate is a 4.6 s LCP.
Deriving budgets from targets¶
Work backwards from a user experience target on a representative device and network, not from a number that sounds nice.
Target: PDP LCP ≤ 2.5 s at p75, mid‑tier Android, 4G (≈1.6 Mbps effective, 150 ms RTT).
Budget = 2500ms total
├─ Connection setup (DNS+TCP+TLS, warm-ish) 200ms
├─ TTFB (server + CDN) 400ms ← requires cached/streamed HTML
├─ HTML download + parse to LCP element 150ms
├─ LCP image discovery (must be in HTML → ~0) 0ms
├─ LCP image download 900ms ← at 1.6Mbps ≈ 180KB max
├─ CSS fetch + parse (blocking) 300ms ← ≈ 60KB compressed
└─ Render + safety margin 550ms
From that decomposition, hard budgets fall out:
| Resource | Budget | Derivation |
|---|---|---|
| LCP image (compressed) | ≤ 180 KB | 900 ms at 1.6 Mbps |
| Critical CSS | ≤ 60 KB | 300 ms budget, render‑blocking |
| TTFB p75 | ≤ 400 ms | Requires CDN cache hit or a streamed shell |
| JS before interactive | ≤ 260 KB | Separately derived below |
JavaScript budget from CPU, not bandwidth. On a mid‑tier Android, budget roughly 1 ms of parse+compile+execute per KB of compressed JS (this is a rough planning heuristic — measure yours). To keep main‑thread blocking under ~350 ms before interactive:
Hence Aurora's 260 KB PDP budget — derived, defensible, and explainable to a PM.
Aurora Market's budget table¶
Budgets differ by page type because constraints differ. A single site‑wide budget is a budget that is simultaneously too loose for checkout and too tight for the homepage.
| Page type | LCP p75 | INP p75 | CLS p75 | TTFB p75 | JS (gz) | CSS (gz) | Images ATF | Requests ATF | 3rd‑party JS |
|---|---|---|---|---|---|---|---|---|---|
| Home | ≤ 2.2 s | ≤ 200 ms | ≤ 0.05 | ≤ 300 ms | 200 KB | 45 KB | 250 KB | 30 | 90 KB |
| PLP | ≤ 2.5 s | ≤ 180 ms | ≤ 0.05 | ≤ 400 ms | 240 KB | 50 KB | 400 KB | 45 | 90 KB |
| PDP | ≤ 2.2 s | ≤ 200 ms | ≤ 0.05 | ≤ 400 ms | 260 KB | 50 KB | 220 KB | 40 | 90 KB |
| Search | ≤ 2.5 s | ≤ 180 ms | ≤ 0.05 | ≤ 500 ms | 240 KB | 50 KB | 350 KB | 45 | 60 KB |
| Cart | ≤ 2.0 s | ≤ 150 ms | ≤ 0.02 | ≤ 500 ms | 200 KB | 45 KB | 120 KB | 25 | 30 KB |
| Checkout | ≤ 1.8 s | ≤ 150 ms | ≤ 0.01 | ≤ 500 ms | 180 KB | 40 KB | 60 KB | 20 | 0 KB ¹ |
¹ Zero non‑essential third parties on checkout. Payment SDKs are essential and excluded; analytics runs server‑side. This is a policy decision, not a technical one, and it's the right one.
Notice the shape: checkout has the tightest budget despite the lowest traffic, because a failure there is a lost order rather than a lost browse. Budget tightness should track revenue risk per event, not traffic volume.
Two kinds of budget¶
Outcome budgets (LCP, INP, CLS, TTFB) are what you actually care about. They're measured in the field, they're noisy, and they lag. Track them weekly on a dashboard; don't block PRs on them.
Proxy budgets (bytes, request counts, module counts, long tasks in a synthetic run) are leading indicators. They're deterministic, they're measurable at build time, and they regress before the outcome does. Block PRs on these.
Proxy budgets → gate every PR (deterministic, fast, fair)
Outcome budgets → gate every release + weekly review (real, noisy, slow)
A PR that adds 40 KB doesn't measurably move p75 LCP. Twenty such PRs do. Proxy budgets are how you catch the twenty.
Enforcing budgets¶
1. Bundle size, per route¶
Next.js prints per‑route First Load JS in the build output. Turn it into a gate:
// scripts/check-bundle-budget.mjs
import { readFile } from 'node:fs/promises';
// Budgets in KB of First Load JS per route pattern
const BUDGETS = {
'/': 200,
'/c/[...slug]': 240, // PLP
'/p/[slug]': 260, // PDP
'/search': 240,
'/cart': 200,
'/checkout': 180,
'__default__': 240,
};
const manifest = JSON.parse(
await readFile('.next/app-build-manifest.json', 'utf8'),
);
// Sum unique chunk sizes per route (see examples/ci for the full version
// that reads real byte sizes from .next/static)
const failures = [];
for (const [route, sizeKb] of Object.entries(await computeFirstLoadJs(manifest))) {
const budget = BUDGETS[route] ?? BUDGETS.__default__;
const status = sizeKb > budget ? '❌' : sizeKb > budget * 0.9 ? '⚠️ ' : '✅';
console.log(`${status} ${route.padEnd(24)} ${sizeKb.toFixed(1)} KB / ${budget} KB`);
if (sizeKb > budget) {
failures.push(`${route}: ${sizeKb.toFixed(1)}KB exceeds ${budget}KB`);
}
}
if (failures.length) {
console.error(`\nBundle budget exceeded:\n ${failures.join('\n ')}`);
process.exit(1);
}
A complete, working version is in examples/ci/bundle-budget.mjs.
2. Lighthouse CI assertions¶
// lighthouserc.js
module.exports = {
ci: {
collect: {
url: [
'http://localhost:3000/',
'http://localhost:3000/c/womens-knitwear',
'http://localhost:3000/p/wool-overshirt-navy',
],
numberOfRuns: 5, // median-of-5, never a single run
settings: { preset: 'desktop' },
},
assert: {
assertions: {
'largest-contentful-paint': ['error', { maxNumericValue: 2200 }],
'total-blocking-time': ['error', { maxNumericValue: 250 }],
'cumulative-layout-shift': ['error', { maxNumericValue: 0.05 }],
'total-byte-weight': ['warn', { maxNumericValue: 1_200_000 }],
'unused-javascript': ['warn', { maxNumericValue: 120_000 }],
'uses-responsive-images': ['error', { minScore: 0.9 }],
'render-blocking-resources':['warn', { maxNumericValue: 300 }],
},
},
upload: { target: 'temporary-public-storage' },
},
};
3. Comment on the PR, don't just fail it¶
A red X with no explanation breeds resentment. Post a diff table:
📦 Bundle impact of this PR
Route Before After Δ Budget
/p/[slug] 241.3 KB 268.9 KB +27.6 KB 260 KB ❌ over by 8.9 KB
/c/[...slug] 229.1 KB 229.1 KB — 240 KB ✅
Largest additions:
+ react-image-gallery 18.2 KB (imported in ProductGallery.tsx:4)
+ date-fns/locale (all) 7.1 KB (imported in ReviewDate.tsx:2)
Suggestions:
• react-image-gallery: consider next/dynamic — the gallery is below the fold
• date-fns: import the single locale you need, not the barrel
That comment tells the author exactly what to do. Full setup: 9.2 CI gates.
When a budget should break¶
Budgets that can never be broken get disabled. Have an explicit process:
The exception form (put it in the PR description):
### Performance budget exception
- Budget: /checkout First Load JS 180 KB
- Requested: 214 KB (+34 KB)
- Reason: new payment method SDK required for EU launch (regulatory deadline Mar 1)
- Expiry: 2026-05-15
- Paydown plan: lazy-load the SDK on payment-method selection (ticket PERF-4412)
- Field impact estimate: +30ms INP on mid-tier mobile at checkout
- Approved by: @perf-guild, @checkout-lead
Rules that keep this honest:
- Exceptions expire. An expiry date and a ticket, always. A CI job lists expired exceptions weekly and files them as bugs.
- Exceptions are visible. A
performance-budget-exceptionlabel and a dashboard of the current total debt. - Approval comes from a group, not the author's manager. A performance guild of 3–4 engineers, rotating.
- The budget doesn't move. Raising the budget to accommodate the code is how sites end up at 700 KB. Grant an exception against the unchanged budget instead — that keeps the debt visible.
The one legitimate reason to permanently raise a budget: the user experience target changed (you now support a market with worse connectivity, so budgets tighten; or you moved to a rendering architecture where the number means something different).
Common mistakes¶
| Mistake | Fix |
|---|---|
| One site‑wide budget | Per page type; constraints differ by an order of magnitude |
| Budget picked as a round number | Derive it from a device + network + UX target |
| Only gating outcome metrics in CI | Too noisy; PRs fail randomly and the gate gets disabled |
| Only gating bytes | Bytes don't capture main‑thread cost of an expensive small library |
| No exception process | Team routes around the gate, or disables it |
| Budgets nobody sees until CI fails | Publish a dashboard; show current headroom in every PR |
| Raising the budget when it breaks | The debt becomes invisible; do a dated exception instead |
Lab 1.5 — Derive and ship one budget¶
- Pick your highest‑revenue page type.
- Derive its LCP budget decomposition (the table at the top of this lesson) using your target device and network. Show your arithmetic.
- Convert it to resource budgets: LCP image KB, critical CSS KB, JS KB, TTFB ms.
- Measure current values. Note the gap for each.
- Ship one gate this week — the bundle size check is the easiest and catches the most.
- Write the exception process into
CONTRIBUTING.mdbefore anyone needs it. Retrofitting a process during an argument never works.
Checklist¶
- Budgets derived from a UX target, with the arithmetic written down
- Per‑page‑type, not site‑wide
- Proxy budgets gate PRs; outcome budgets gate releases and get a weekly review
- CI posts an informative comment, not just a failure
- Written exception process with expiry dates and a visible debt list
- Someone owns the budgets (a named person or guild)
Next: Module 2 — Quick wins