Web Performance Budgets: Making Speed a Discipline
Performance is not something you optimize occasionally; it must be maintained continuously. A performance budget sets a line that speed must never cross. This article explains how to allocate budgets across JS, images, and CSS, enforce them automatically in CI, and handle overruns by cutting features, optimizing, or negotiating.
Key Points
- Topic Focus: Next.js、React
- Suitable for: indie hackers, full-stack engineers, AI application developers
- Core value: quickly understand background, practical paths, common pitfalls, and engineering advice
Anyone who has worked in frontend development for a few years has probably seen this cycle: a project launches and the pages are blazing fast, with LCP just over one second; six months later, people start complaining, "Why is it getting more and more sluggish?"; after a year, Lighthouse shows that the performance score has fallen from 95 to 60, and the boss demands "a dedicated performance optimization initiative"; after two weeks of optimization, the score is back to 85 and everyone celebrates; six months later, the same story repeats itself.
The root cause of this cycle is not technology but management. Performance is not a "project" that one sprint can solve. It is a waterline that must be maintained every day. What your team lacks is not better optimization techniques, but an uncrossable line that blocks slowdowns at the PR stage instead of letting them accumulate until users start cursing.
That line is called a Performance Budget.
1. Performance Regresses Unless You Defend It: That Is a Physical Law
Start with one basic fact: applications only get slower over time; they do not automatically get faster.
The reason is simple. Almost every product iteration adds something to the page: a new feature needs another JS component, operations wants more analytics, the growth team wants an A/B testing SDK, design wants another font family, advertising wants a third-party script, and the boss wants a full-screen video banner. Each PR adds "only 30KB," and nobody is going to pound the table over 30KB. But after 200 PRs in a year, that becomes 6MB of dead weight.
More insidiously, slowness accumulates nonlinearly:
- A larger JS payload does not just take longer to download. More importantly, parsing, compilation, and execution all consume main-thread time and directly destroy INP;
- One more image introduces another LCP candidate and can push above-the-fold text outside the viewport;
- As third-party scripts multiply, the main thread stays occupied for longer periods and INP heads straight toward 500ms;
- Add another font and FOUT/FOIT becomes harder to control, while the rendering time of LCP text becomes less predictable.
That is why "occasional optimization" is a false premise. You cannot clear six months of accumulated debt in one optimization sprint every six months, just as fasting once a year cannot undo a year of overeating. The only solution is to prevent the debt from accumulating, which is exactly why budgets exist.
2. What Is a Performance Budget? A Ledger for "Fast"
A performance budget sets upper limits for a page's key metrics, like a household ledger: how much goes to the mortgage, food, and entertainment this month? Together, they cannot exceed your income. If they do, an alarm goes off and spending gets cut.
The difference between a budget and "performance optimization" is:
- Optimization is reactive, passive, and one-off: the application is already slow, so you find a way to speed it up;
- A budget is preventive, proactive, and continuous: before the application becomes slow, you define how slow it is allowed to get.
A budget should cover metrics at two levels.
Resource-size level (what you can control directly):
| Metric | Meaning | Typical Budget Limit (Mobile) |
|---|---|---|
| JS bundle | Total JS loaded for the initial view | ≤ 170KB gzip |
| Route JS | JS loaded on demand for one route | ≤ 80KB gzip |
| CSS | Above-the-fold Critical CSS | ≤ 30KB gzip |
| Images | Total above-the-fold image payload | ≤ 100KB |
| Fonts | Web Font count / size | ≤ 2 fonts, ≤ 50KB each |
| Third-party scripts | Total scripts from external domains | ≤ 50KB gzip |
| Total transfer size | All resources for the initial view | ≤ 500KB |
User-experience level (what users actually perceive):
| Metric | Meaning | Good Threshold | Recommended Budget |
|---|---|---|---|
| LCP | Largest Contentful Paint time | ≤ 2.5s | ≤ 2.0s (with headroom) |
| INP | Interaction latency (replaced FID in 2024) | ≤ 200ms | ≤ 150ms |
| CLS | Cumulative Layout Shift | ≤ 0.1 | ≤ 0.05 |
| TTFB | Time to First Byte | ≤ 0.8s | ≤ 0.6s |
You need both levels, and resource budgets are the means while experience budgets are the goal. Resource sizes are easy to measure and gate, so check them in CI. Experience metrics are noisy and harder to measure, so track their trends in weekly RUM (Real User Monitoring) reports.
3. How to Set Budgets Without Endless Arguments
Set the budget too high and it is meaningless; set it too low and nobody can build anything. How do you choose a credible number? Stand on three pillars: your target users' environment, your current baseline, and industry benchmarks.
First pillar: your target users' devices and networks.
This is the most important basis and the easiest to overlook. Are your users programmers in office buildings using M2 MacBooks on corporate gigabit Wi-Fi, or users in less-developed markets browsing over 4G on three-year-old budget phones? A tenfold difference in budgets would not be surprising.
A practical approach is to run Lighthouse or WebPageTest with a "representative device + representative network" for your target users, such as Moto G Power + Slow 4G (1.6Mbps downlink, 150ms RTT). In that environment, the CPU is 4-6 times slower than a desktop CPU, the network is 10 times slower, and JS parsing time grows exponentially. If you can keep LCP below 2.5s under that configuration, you probably have a reasonable budget.
Second pillar: your own baseline.
A budget cannot ignore the current state. If a three-year-old project that has never been optimized ships 800KB of initial JS, declaring that "starting today, JS must never exceed 170KB" will make everyone give up immediately because the target is impossible.
The right approach is a progressive budget: first measure the current P75 (the 75th percentile, not the average), set a hard short-term limit that says "it cannot get worse than it is now," and then define a target for three or six months later and gradually work toward it. For example:
- Current P75 JS: 520KB gzip
- Short-term budget (effective immediately): ≤ 520KB (it may only decrease, never increase)
- Quarterly target: ≤ 400KB
- Annual target: ≤ 250KB
"It cannot get worse" matters more than "it must reach a specific value." It turns gradual, unnoticed performance decay into something blocked at every step. That rule alone can eliminate 80% of a team's after-the-fact optimization costs.
Third pillar: industry benchmarks.
The HTTP Archive's Web Almanac and CrUX reports contain real data by industry and can serve as anchors. Once you know the P75 LCP for e-commerce, news, SaaS, and the broader market, you can tell whether your budget is ambitious or whether you are simply coasting.
4. Itemized Budgets: Do Not Set Only One Total
A beginner's mistake is to define only "total JS ≤ 300KB," then watch everything collapse as soon as it is exceeded while the team has no idea who is responsible. A good budget is itemized, with a clear owner for every item.
Total JS budget + route budgets. The total budget protects the whole application, while route budgets prevent one page from carrying the blame for everything. In Next.js, for example, you might cap a critical route such as /checkout at 100KB while allowing /dashboard up to 150KB, preventing one route from consuming the entire global budget.
Initial load vs on demand. Calculate initial-load JS (downloaded and executed immediately) separately from on-demand JS (loaded lazily). The initial-load budget must be strict because every KB directly affects LCP and INP. The on-demand budget can be looser because it does not affect the initial view.
Image budget. Divide it by purpose: apply a separate cap to the above-the-fold LCP image (file size + dimensions), while allowing more room for other images. Require every image to use <img> with loading="lazy", srcset/sizes, a modern format (AVIF/WebP), and real-time CDN compression.
Font budget. In one sentence: do not use a third font. If a company's entire site uses more than 4-5 font files, including weight variants, its budget is out of control. Prefer font-display: swap, subset Chinese fonts (dynamic subsetting is essential for Chinese), and preload critical fonts.
Third-party script budget. This is the hardest rule to enforce and the one most worth enforcing. Analytics, A/B testing, customer support, payments, advertising: every third party wants to inject a script into your head, and every one says, "Mine is only 5KB." The result is that an e-commerce homepage with 30 third-party domains and 300KB of third-party JS in its head is perfectly normal. Fix a strict allowlist and a total cap, such as ≤ 50KB, and require a performance review for every new third party.
5. Automated CI Checks: A Budget Outside the Pipeline Is Decoration
Nobody reads a budget written in a wiki, and nobody pays attention to one posted on the wall. It must gate PRs to be effective. A budget that is not enforced in CI is no budget at all. Here are configurations for three common tools. Choose any one of them. The key is that when a PR exceeds the budget, CI turns red and the PR cannot merge.
size-limit (my top recommendation for Next.js / Vite / webpack projects). It is easy to configure, targets JS bundles, and can enforce limits per route:
// package.json
{
"size-limit": [
{ "path": "dist/static/chunks/main.js", "limit": "80 KB", "name": "Main" },
{ "path": "dist/static/chunks/vendor.js", "limit": "120 KB", "name": "Vendor" },
{ "path": "dist/static/pages/**/*.js", "limit": "50 KB", "name": "Per-route" }
],
"scripts": {
"size": "size-limit",
"ci": "npm run build && npm run size"
}
}
Run npm run ci for the PR. If any item exceeds its limit, the command exits with code 1 and GitHub Actions turns red immediately.
Lighthouse CI (for experience metrics such as LCP/CLS/TBT). Experience metrics are noisy, so you cannot hard-gate on a single run. The correct approach is to assert against the median and compare it with a baseline:
# .github/workflows/lhci.yml
- uses: treosh/lighthouse-ci-action@v11
with:
urls: |
https://preview.example.com/
https://preview.example.com/product/123
budgetPath: ./lighthouse-budget.json
uploadArtifacts: true
temporaryPublicStorage: true
configPath: ./lighthouserc.json
// lighthouserc.json
{
"ci": {
"assert": {
"preset": "lighthouse:no-pwa",
"assertions": {
"largest-contentful-paint": ["error", { "maxScore": 0.9 }],
"cumulative-layout-shift": ["error", { "maxScore": 0.9 }],
"total-blocking-time": ["error", { "maxScore": 0.9 }],
"resource-summary:script:size": ["error", { "maxNumericValue": 175000 }]
}
}
}
}
bundlesize / bundlewatch. These are the lightest options. They only track gzipped size and are good for getting started:
{
"files": [
{ "path": "dist/main.js", "maxSize": "80 KB" },
{ "path": "dist/vendor.js", "maxSize": "120 KB" }
]
}
One practical recommendation: do not make experience metrics such as LCP and INP a "hard fail"; fail only when they are more than 10% worse than the previous week's baseline. They are sensitive to the environment, so a small machine fluctuation can move the result. Resource sizes such as JS and CSS can be hard-gated because they are deterministic, reproducible, and have clear ownership.
6. What to Do When You Exceed the Budget: Cut, Optimize, Negotiate
Once the budget is in place, the real test arrives: a PR pushes JS from 165KB to 180KB and exceeds the limit. What do you do?
Your first reaction should not be, "I'll optimize it and squeeze it back under the limit." Ask three questions first:
- Is this 15KB truly necessary?
- Is there a cheaper alternative to this 15KB?
- Is this 15KB valuable enough to justify a temporary budget increase?
Answer them in order, corresponding to the three tactics: cut, optimize, and negotiate.
Cut (cheapest): run an unused-import check with knip, depcheck, or webpack-bundle-analyzer. In 90% of projects, you can immediately remove 10-30% dead code. Then ask again: must this new feature be in the initial view? Can you degrade it gracefully and lazy-load secondary functionality? Does the rich-text editor really need to load initially, or can it load only after the user clicks Edit? Cutting a feature is not admitting defeat; it is an engineering judgment.
Optimize (moderate cost): if you cannot cut it, optimize it. Common techniques in ROI order:
- Code splitting / route-level lazy loading: use Next.js dynamic
import()or Reactlazy()to move noncritical code into on-demand chunks. The effect is immediate. - Switch to lighter libraries:
moment→date-fnsordayjs(280KB → 2KB),lodash→ on-demandlodash-esimports or native APIs, and removemoment-timezoneif possible. This delivers the best value for the effort. - Audit tree-shaking: confirm
sideEffects: false, confirm ESM imports, and make sure you did not useimport * fromfor an entire UI library. - Precompile vs runtime: do not calculate at runtime what can be calculated at build time, and do not render in a client component what can be rendered in a server component.
Negotiate (most expensive, but sometimes necessary): if the feature genuinely delivers value and cannot be optimized further, negotiate with data in hand. A budget is not sacred law; it is a mechanism for reaching consensus. In the PR description, state clearly: "This change adds 15KB to initial JS, is estimated to add 120ms to LCP, and delivers business value X." Then let the owner (technical lead / PM) decide whether to raise the budget temporarily.
The point is not "never add anything." The point is to add it transparently and pair every increase with a debt-repayment plan. For example, allow the budget to rise to 195KB this time while committing to split the vendor chunk in the next iteration and bring the budget back to 175KB. If you make concessions without repaying the debt, the budget will gradually inflate into a useless piece of paper.
7. Turn the Budget into a Dashboard the Whole Team Can See
For a budget to work, a red CI check is not enough. People must see it every day. Numbers nobody sees effectively do not exist.
Build a dashboard using Lighthouse CI Server, SpeedCurve, or your own RUM data in Datadog / Grafana. It should answer four questions:
- How far are we from the red line? Current resource size / experience metrics vs the budget, including the remaining headroom.
- Who moved the red line recently? Show the resource-size trend over the last 30 days and annotate every jump with the corresponding PR.
- Which route is most at risk? Rank routes by budget usage and highlight those close to exceeding it.
- Is the trend converging or diverging? Show whether performance has gradually improved or deteriorated over the last three months.
Pin the dashboard link at the top of the Slack channel, display it on the stand-up screen, and place it on the wiki homepage. When every engineer can see each day that "my route has only 8KB of headroom left," they will make more deliberate decisions while coding. The purpose of visualization is to turn abstract "performance" into concrete numbers and turn "the team's problem" into "my problem."
8. Culture: Treat Performance as a Feature and the Budget as a Contract
Once the tools and processes are in place, the last mile is culture. A budget without cultural support will become meaningless within three months.
Here are several concrete, practical cultural changes:
Performance is a feature, not a nonfunctional requirement. Give it a field in the requirements document, an item in the release checklist, and a number in the OKRs. Your team's attitude toward performance is ultimately a reflection of leadership's attitude toward performance.
Add one line about performance to the PR template. Require every PR description to include: "Performance impact of this change: initial JS +/-X KB / LCP +/-Y ms / no impact." Even if 90% of people write "no impact," the remaining 10% who report an impact give reviewers a signal that the change deserves closer scrutiny. Major open-source projects on GitHub have done this for years.
Institutionalize performance reviews. Any PR above a defined threshold, such as more than 20KB of new JS or a new third-party script, must be reviewed by the performance owner. Make it part of the process, like code review, instead of something done only when someone remembers.
Reserve time for "debt repayment." Allocate 10-15% of every iteration to performance maintenance, just as operations reserves capacity for incidents. Without reserved capacity, that debt will never be repaid and will only keep growing.
Do not shame people; hold retrospectives. If a PR exceeds the budget, do not mention the author publicly in a group chat. Review why CI did not block it. Was the budget unreasonable, or did the tooling miss a scenario? Problems in the budgeting system are more worth fixing than a single budget overrun.
Summary
Performance budgets are not technically difficult. The challenge is discipline and persistence. Remember these essentials:
- Slowness accumulates, so it can only be prevented, not cured. Instead of a dedicated optimization initiative every six months, use a daily budget to stop regressions before they pass the PR stage.
- Itemize the budget instead of setting only one total. Total JS + route budgets + images + fonts + third parties each need their own red line and owner.
- A budget outside CI is an empty policy. Choose either size-limit or Lighthouse CI, hard-gate resource sizes, and compare experience metrics against a baseline.
- When a change exceeds the budget, cut, optimize, or negotiate instead of silently optimizing until it barely fits. Any temporary increase must include a debt-repayment plan, or the budget will inflate into decoration.
- The budget must be visible every day. Dashboards, PR templates, performance reviews, and debt-repayment capacity turn "performance" from the team's concern into everyone's concern.
Performance is not the finish line of a single sprint. It is a waterline that must be defended every day. The budget is that line.
Related
Related Content
Article
Is Next.js App Router a Good Fit for a Technical Community?
Technical trends, engineering practices, and independent development perspectives centered on Next.js and React.
Article
SEO Design for a Content Community with Next.js
Technical trends, engineering practices, and an indie developer's perspective on Next.js and TypeScript.
Article
How Should a Technical Community Design Its Tag System and Recommendation Feed?
Centered on independent development, Next.js technology trends, engineering practices, and the indie developer perspective.
Article
The Engineering Value of TypeScript in AI Application Frontends
Technical trends, engineering practices, and an independent developer perspective on TypeScript and React.