Skip to content

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:

350ms ÷ ~1.3ms/KB ≈ 270KB compressed JS

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:

  1. Exceptions expire. An expiry date and a ticket, always. A CI job lists expired exceptions weekly and files them as bugs.
  2. Exceptions are visible. A performance-budget-exception label and a dashboard of the current total debt.
  3. Approval comes from a group, not the author's manager. A performance guild of 3–4 engineers, rotating.
  4. 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

  1. Pick your highest‑revenue page type.
  2. Derive its LCP budget decomposition (the table at the top of this lesson) using your target device and network. Show your arithmetic.
  3. Convert it to resource budgets: LCP image KB, critical CSS KB, JS KB, TTFB ms.
  4. Measure current values. Note the gap for each.
  5. Ship one gate this week — the bundle size check is the easiest and catches the most.
  6. Write the exception process into CONTRIBUTING.md before 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