The performance budget every web app needs (and how to enforce it)
Performance is not a launch task — it is a budget line. The exact budgets we set on every client project, the tooling that enforces them in CI, and the exceptions policy that keeps it honest.
Abdenassar Amimi
Founder & Lead Engineer
May 14, 2026
6 min read
Every fast app we have shipped had the same property: it was never allowed to be slow. Not "we optimized it later" — the budget existed before the first component was written, and CI rejected anything that broke it. Performance is a budget line, not a cleanup sprint.
The budgets we set by default
- →LCP ≤ 2.0s at p75 on a mid-tier Android over 4G. Google’s "good" is 2.5s; we leave margin for the bad days.
- →Total JS shipped on initial load ≤ 170KB compressed. Frameworks included. It is aggressively doable with server components.
- →Interaction to Next Paint ≤ 200ms on the core loop actions.
- →CLS ≤ 0.05 — reserved space for every image and embed, no exceptions.
The JS budget is the one people argue about, so here is the math: 170KB compressed is roughly the entire runtime of a well-built server-component app plus one meaningful client island. Every time a team asks for 500KB, we ask which of their users asked for it. None did. Their product manager’s demo laptop did.
Enforcement lives in CI, or it does not exist
# .github/workflows/perf.yml (essentials)
- name: Build and measure
run: |
pnpm build
pnpm exec lhci autorun --collect.staticDistDir=.next
env:
LHCI_BUDGETS: |
[
{ "path": "/", "resourceSummary": { "script": { "maxNumericValue": 170000 } } }
]Lighthouse CI on the PR, budget file in the repo, red build on regression. The red build is the entire system. Dashboards do not change behavior; blocked PRs do. We track field data (real users) separately in a weekly digest — lab data gates the merge, field data catches the things lab data cannot see: your actual users’ devices, networks, and third-party scripts.
The exceptions policy
Rigid budgets get bypassed the first time a legitimate exception appears, so we write the exceptions policy into the budget file itself. An exception needs three things: a named owner, an expiry date, and a linked issue for the paydown. "Temporary" exceptions without expiry dates are how 170 becomes 340 by December.
A performance budget with no exceptions policy is not a budget. It is a New Year’s resolution.
Set the budgets before the first sprint, put the gate in CI before the first PR, and write down the exceptions policy the first time you need one. That is the entire system. Everything else is just enjoying being the fast app in your category.
have-a-project-like-this
This is the kind of thinking we bring to client work. If it resonates, the discovery call is free.
Talk to uskeep-reading
Engineering · 8 min
Next.js 16 in production: what we learned shipping 12 apps
Turbopack is no longer optional, cache components change how you think about data, and partial prerendering quietly became the default. Our field notes from twelve production upgrades.
Design Systems · 7 min
Design tokens: the contract between design and code
Most token systems fail silently — a Figma variable nobody renames, a hex value pasted into a component at 11pm. Here is the token architecture we use to keep design and code honest.