What you'll learn: By the end of this lesson you will be able to describe the three kinds of performance budget (quantity limits, timing or user-centric metrics, rule-based scores), choose limits from your own data instead of copying numbers, enforce them at the points where a change is made (build, CI, production), read a metric budget as a percentile over enough samples, and use the Chrome DevTools Performance panel to go from "the budget failed" to "this function on the main thread is the cause". The lab is a small budget checker that turns a stats file into a pass or fail report.
At an airport you are weighed at check-in, not at the gate. The limit is agreed in advance, it is checked while you can still do something about it (take the book out of the bag), and when you are over you decide what to leave behind. Nobody argues about the 23rd kilogram at the aircraft door. A performance budget is that check-in desk for a web page.
Web.dev defines it as "a set of limits imposed on metrics that affect site performance", used as "a point of reference for making decisions about design, technology, and adding features". The Core Web Vitals lesson introduced budgets as a design artefact. This lesson is the working detail: what kinds there are, how to enforce them, and what to do after one fails.
The running example is the store's product page again, with a build pipeline that checks every pull request. The numbers in this lesson are illustrative, taken from hand-written fixture data in the lab, except the Core Web Vitals "good" values, which are from web.dev.
The core idea
- A budget is a limit agreed before the change, checked where the change is made. A limit nobody checks until production is a wish.
- A budget says that something regressed. A profile says why. The two are a pair: the check finds the pull request, the profile finds the function.
- A number is only as good as its measurement. Bytes from a build are exact. A metric at the 75th percentile of a handful of runs is noisy, and a good checker says "no data" instead of guessing.
Three kinds of budget
Web.dev's guide names three, and advises combining the first two.
| Kind | Example | Good at | Weak at |
|---|---|---|---|
| Quantity | Script on the product route at most 200 KiB; third-party requests at most 4 | Early warning, easy to explain to designers and developers, checked in CI | Does not capture the experience: load order matters, not just totals |
| Timing or user-centric metric | LCP at most 2.5 s at p75 | Measures what users feel | Needs measurement, and noise makes it slow to attribute |
| Rule-based | A Lighthouse score above a threshold | A single number, with hints for simple fixes | One number hides which input moved |
Web.dev's thresholds for the Core Web Vitals, at the 75th percentile of page loads segmented across mobile and desktop: LCP within 2.5 seconds, INP of 200 milliseconds or less, CLS of 0.1 or less. Those are a sensible metric budget to start from. Everything else (bytes, counts) you choose.
Choosing the numbers. Start from your own data: what the page weighs and scores now, and what the slowest important device and network can take. Web.dev suggests testing, studying competitors, and setting budgets per page type (its examples: a product page limited in JavaScript, a search page limited in images). Its fallback defaults (a Time to Interactive target and about 170 KB for critical-path resources) come from that article's baseline device and network; do not copy them, because your pages and devices differ.
What happens when a change breaks one. The guide's three options: optimise it, remove something else, or do not add it. A budget turns a vague debate ("it is only one library") into a trade ("what do we drop for it?").
Where to enforce
Figure 1. Budgets at four points
How to read this: the cheapest, fastest checks come first. A failing check sends the author to a profile, not to a debate. Field data comes last and is the truth about users, but it arrives after the change has shipped.
- Build time. The build output has exact numbers. Web.dev's build-tool guide describes webpack performance hints that warn when assets or entry points exceed a limit (its default is 250 KB, measured on uncompressed asset sizes), size-limit tools that check files, usually gzipped, and CI jobs that block a pull request when a check fails.
- A lab run. Lighthouse accepts a budget file. Web.dev's example has
path,timings,resourceSizesandresourceCountsentries (for instance"resourceType": "script"with a"budget"in kilobytes), and the report gets a Budgets section under Performance. The file can be named anything and is passed with--budget-path. A lab run is controlled but is not your users. - Production. Field metrics at p75, alerting when they cross a limit. Web.dev adds that you should keep measuring after launch and collect data from real users.
| Point | Speed of feedback | Truthfulness | Typical gap |
|---|---|---|---|
| Build check | Seconds | Exact for bytes, silent about experience | Misses what is requested at runtime |
| Lab run | Minutes | Controlled, repeatable | A fast CI machine, no real third parties |
| Field | Days | Real users | Slow to attribute, needs volume |
Metric budgets are percentiles over samples
A metric budget compares one number to a limit, and that number is a percentile of many page views. For the Core Web Vitals web.dev uses the 75th. Two consequences.
- Use the stated percentile, not the worst case or the mean. The worst single visit is almost always bad; the median hides the slow quarter of users.
- Enough samples or no verdict. A p75 of six runs is not a p75. The lab's checker uses nearest-rank (the value at rank
ceil(p/100 * n)of the sorted samples) and returns no data, which fails the run, when a metric has fewer samples than a stated minimum. That is a design choice, and the minimum is yours to set.
Likewise a size budget on a pattern that matches nothing, say after a bundler renamed its output, should not pass silently.
Build it: a budget checker over a stats file
Goal. Check a build's measurements (bundle sizes, request counts and sizes per page, metric percentiles) against a budget file and read the pass, warn, fail and no-data results the way a CI job would.
Setup. The lab is examples/frontend/labs/perf-budget-checker. It needs Node.js (captured on Node 26.12.1).
cd examples/frontend
pnpm install
cd labs/perf-budget-checker
pnpm test # 36 tests
node src/demo.ts
node src/cli.ts data/budget.json data/stats-pr.json --baseline data/stats-main.json; echo $?
Steps.
- Open
data/budget.json. The first entry that matches a page's path wins, so specific paths come first (section 3 shows/product/a/bmatching nothing, because*stays inside one path segment). - In section 4 (the main branch), find the two
WARNlines: within 90% of a limit, still passing. The run passes. - In section 5 (the pull request), read the five
FAILlines. The page-level script total (357 KiB) is over by more than the entry bundle alone, because the page also requests a charting chunk that no bundle pattern names. - Find the
NODATAline: only 6 INP samples, need 10. The run fails on it, because a budget that cannot be measured is not passing. - Read section 7: the command line exits 0, 1 or 2.
Expected output, captured from a real run (Node 26.12.1, 2026-11-08):
=== 1. The budget file (data/budget.json)
page entries: /product/*, /
bundle limits: assets/index-*.js <= 120 KiB; assets/vendor-*.js <= 90 KiB; assets/*.css <= 30 KiB
warn at 90% of a limit; a percentile needs at least 10 samples
=== 2. Nearest-rank percentile (the product page's 12 LCP samples, ms)
sorted: 1650, 1720, 1810, 1900, 1950, 2010, 2100, 2150, 2200, 2300, 2380, 2900
p50 = 2010 (rank 6 of 12)
p75 = 2200 (rank 9 of 12)
p90 = 2380 (rank 11 of 12)
=== 3. Which budget entry applies to a path (first match wins)
/ -> /
/product/kettle-2l -> /product/*
/product/a/b -> none (not checked)
/about -> none (not checked)
=== 4. The main branch build
build main@a1b2c3d (illustrative)
PASS / size script: 124 KiB <= 150 KiB
PASS / size total: 340 KiB <= 900 KiB
PASS / count third-party: 1 requests <= 3 requests
PASS / metric lcp p75: 1850 ms <= 2500 ms (n=10)
WARN /product/kettle-2l size script: 195 KiB <= 200 KiB
PASS /product/kettle-2l size image: 381 KiB <= 600 KiB
PASS /product/kettle-2l size total: 674 KiB <= 1200 KiB
PASS /product/kettle-2l count third-party: 3 requests <= 4 requests
PASS /product/kettle-2l count total: 15 requests <= 40 requests
PASS /product/kettle-2l metric lcp p75: 2200 ms <= 2500 ms (n=12)
PASS /product/kettle-2l metric inp p75: 168 ms <= 200 ms (n=12)
PASS /product/kettle-2l metric cls p75: 0.08 <= 0.10 (n=12)
WARN bundle assets/index-*.js: 112 KiB <= 120 KiB (1 file)
PASS bundle assets/vendor-*.js: 62 KiB <= 90 KiB (1 file)
PASS bundle assets/*.css: 24 KiB <= 30 KiB (1 file)
NOTE /about: no budget entry matches this page, so it was not checked
result: PASS (13 pass, 2 warn, 0 fail, 0 no data)
=== 5. A pull request build
build pull-request-1234 (illustrative)
FAIL / size script: 180 KiB > 150 KiB
PASS / size total: 396 KiB <= 900 KiB
PASS / count third-party: 1 requests <= 3 requests
PASS / metric lcp p75: 1850 ms <= 2500 ms (n=10)
FAIL /product/kettle-2l size script: 357 KiB > 200 KiB
PASS /product/kettle-2l size image: 381 KiB <= 600 KiB
PASS /product/kettle-2l size total: 836 KiB <= 1200 KiB
FAIL /product/kettle-2l count third-party: 5 requests > 4 requests
PASS /product/kettle-2l count total: 18 requests <= 40 requests
FAIL /product/kettle-2l metric lcp p75: 2700 ms > 2500 ms (n=12)
NODATA /product/kettle-2l metric inp p75: no value (limit 200 ms) (6 samples, need at least 10)
PASS /product/kettle-2l metric cls p75: 0.05 <= 0.10 (n=12)
FAIL bundle assets/index-*.js: 168 KiB > 120 KiB (1 file)
PASS bundle assets/vendor-*.js: 62 KiB <= 90 KiB (1 file)
PASS bundle assets/*.css: 24 KiB <= 30 KiB (1 file)
NOTE /about: no budget entry matches this page, so it was not checked
result: FAIL (9 pass, 0 warn, 5 fail, 1 no data)
=== 6. Bundle sizes against the main branch
assets/index-*.js: 112 KiB -> 168 KiB, +56.0 KiB (+50.0%)
assets/vendor-*.js: 62 KiB -> 62 KiB, +0.0 KiB (+0.0%)
assets/*.css: 24 KiB -> 24 KiB, +0.0 KiB (+0.0%)
=== 7. The command line, as CI would call it
main exit code 0
pull request exit code 1
missing file exit code 2
=== 8. A bad budget file is rejected with a pointer to the problem
budget.pages[0].resourceSizes[0].resourceType must be one of document, script, stylesheet, image, font, media, other, third-party, total
budget.pages[0].metrics[0].percentile must be above 0 and at most 100
This is a model, not Lighthouse or a CI product. The stats are hand-written and illustrative (the hosts are example domains), the budget format is the lab's own (inspired by web.dev's guides, not compatible with Lighthouse's budget.json), and the percentile uses a nearest-rank definition that a vendor's tool may compute differently. The lab did not check how Lighthouse matches a budget path to a URL.
Break it. In src/budget.ts, change the no-data branch of the metric check to record "pass". Three tests fail, and the pull request would go green with an INP number nobody measured. Revert.
Stretch. Add a rule that fails when any page in the stats file has no budget entry, and think about why a "NOTE" is not enough.
Profiling: from a failed budget to a cause
A budget tells you which change broke something and by how much. To fix it you need to see what the main thread did.
Figure 2. The profiling loop in DevTools
in Capture settings"] --> B["Record: reload,
or interact and stop"] B --> C["Main track:
find long tasks (red marks)"] C --> D["Select the task:
Call tree for the root cause"] D --> E["Bottom-up: functions with
the most self time"] E --> F["Change one thing"] F --> G["Record again, compare"]
How to read this: profile first with throttling, because a developer laptop is faster than the devices that decide your field numbers. Change one thing at a time and record again.
What the Chrome DevTools pages say to use:
- Throttle. "Whenever you profile a page, use CPU Throttling to simulate how your page performs on mobile devices." Throttling is relative to your computer's capabilities, and desktop throttling cannot fully simulate a mobile CPU, so treat the result as a hint about direction.
- Record. Record and reload for page load, or record while you interact and then stop. Screenshots capture frames.
- Live metrics and Insights. The panel opens with the page's LCP, CLS and INP for the current session, which helps find problems that only appear in certain circumstances, and an Insights sidebar lists issues similar to Lighthouse's, highlighting the related events.
- The Main track. Its x-axis is time and its y-axis is the call stack: events on top cause the events below. A wider bar means the event took longer. Long tasks are marked with a red triangle, with the part over 50 ms shaded red.
- Tables. Call tree shows which root activities cause the most work. Bottom-up shows which activities consume the most time directly. Event log shows order. Self time is time spent directly in an activity; total time includes its children. A function with large total time but small self time is a caller; the one with large self time is where the work is.
Back to the example: the pull request added a charting library. The profile shows one long task at startup whose bottom-up view puts most self time in the library's initialisation. That is a cause, and it points at a fix: load the chart only when the reviews tab opens. See code splitting.
Figure 3. A pull request check and its follow-up
How to read this: the checker states what regressed and where. The profile supplies the reason. Neither replaces the other.
Trade-offs
| Choice | One side | The other side |
|---|---|---|
| Hard fail or warning | A fail stops regressions; teams learn the budget | A noisy fail teaches people to ignore or raise it |
| Global or per-route limits | Simple | Per-route limits fit page types (web.dev's advice) but need maintenance |
| Tight or loose limits | A small bundle forever | Headroom avoids a fight every sprint; a warning band (90% in the lab) gives notice |
| Lab or field metrics | Lab is fast and repeatable | Field is the truth and arrives late |
| Bytes or metrics | Bytes are exact and cheap | Metrics reflect experience; use both |
Illustration 1: the minimal case. One limit: the entry bundle at most 120 KiB. A pull request grows it from 112 to 168 KiB. The check fails with "168 KiB > 120 KiB (+50%)" and the author sees it in minutes.
Illustration 2: the realistic case. The product page has script, image and total byte limits, third-party and total request-count limits, and LCP, INP and CLS at p75, as in data/budget.json. The home page has its own, tighter, script limit. The warning band shows the product page at 195 of 200 KiB: still passing, and a signal that the next library will not fit.
Illustration 3: the failure case. The entry-bundle limit passes on a pull request that adds a 48 KiB chart chunk loaded by the product route. The bundle patterns do not name the chunk, so they stay green; the page-level script total, which counts what the page actually requests, is where it shows up (357 KiB against 200). Bundle budgets see files; page budgets see requests. Keep both.
In practice: a regression the budget did not see
This is a realistic scenario, not a case history. It is a composite built from common production patterns. It describes no specific company, and every number is an illustrative assumption.
The situation
The store's front-end team set per-route budgets and a CI check a quarter ago. Since then, LCP at p75 on mobile drifted from 2.2 s to 2.8 s (illustrative), and nobody noticed because CI stayed green. Five engineers ship to the product page weekly.
Starting architecture
Figure 4. The pipeline as built
How to read this: the checker only judges pages the budget file names. A page that moves to another path leaves the file's patterns behind, and field metrics are reviewed by people, not by the pipeline.
Options considered
| Option | What it fixes | What it costs | Risk |
|---|---|---|---|
| Tighten every limit | Smaller pages | Constant red builds | Teams raise limits to unblock |
| Fail when a page has no budget entry, and when a pattern matches nothing | Closes the silent gap | A one-time cleanup | Failing builds on harmless new pages |
| Alert on field p75 as well | Catches what CI cannot | An on-call signal | Noise from low traffic |
The decision
| Field | Decision record |
|---|---|
| Context | A route was renamed; the budget entry for the old path matched nothing; CI skipped the page. |
| Decision | Fail the check for any page without a budget entry, and for any bundle pattern with no matching file; add a field-metric alert at p75 per route with a minimum sample size. |
| Consequences, good | No page can be silently unbudgeted; field drift is seen within days. |
| Consequences, bad | New pages need a budget entry on day one. |
| Revisit when | Alerts fire without a user-visible change. |
Rollout and verification
- Replay the last quarter's builds through the stricter checker to see which would have failed, and when.
- Add the route list (from the router or sitemap) as a test input, so a route without a budget entry fails.
- Alert on field p75 per route only when the sample count is above a floor.
- Profile the regression in a throttled run to find the cause before choosing a fix.
The 2 a.m. failure
An on-call alert fires: product-page LCP at p75 crossed its limit overnight, but CI is green. First three checks: (1) compare the route in the alert with the paths in the budget file; (2) look for a recent deploy that changed what the page requests (a new third-party script, a tag-manager rule) rather than what the bundle contains; (3) record a throttled profile and look at the first long task. The tag manager had started loading a script after the last release, outside the build output, so no build-time check could see it. Mitigation: disable the tag. Follow-up: a lab run in CI that counts third-party requests on the live-like page, and a request-count budget for them.
Architect's notes
- Budget what you can act on. Bytes and request counts in CI; metric percentiles in production; one owner per budget.
- Prefer a warning band to a cliff. A band tells teams the next change will not fit, before the fail.
- Make "no data" a failure. An unmeasured budget is a gap.
- Keep budgets and routes in step. Test that every route has an entry and every pattern matches a file.
- Profile before you optimise. A budget names the pull request; only a trace names the function.
- Do not copy others' numbers. Set limits from your own field data and revisit them quarterly.
Common mistakes
- Setting a budget and never checking it where the change is made.
- Using the mean or the worst case instead of the stated percentile.
- Trusting a percentile built from a handful of samples.
- Budgeting only the entry bundle and missing chunks and third parties the page requests.
- Letting a renamed route or pattern leave a page unbudgeted.
- Profiling on a fast, unthrottled machine and concluding "it is fine".
- Raising the limit every time it fails.
Key takeaways
- A performance budget is a limit agreed in advance and checked where the change is made.
- Combine quantity budgets (bytes, counts) with metric budgets at a stated percentile.
- Set budgets per page type, from your own data; the Core Web Vitals "good" values (2.5 s, 200 ms, 0.1 at p75) are a starting metric budget.
- Enforce in build, in a lab run and in the field; each has a different speed and truthfulness.
- A budget with too few samples, or no matching file or page, is "no data" and should fail.
- Use DevTools with CPU throttling: long tasks, the call tree, and bottom-up self time lead from a failed budget to a cause.
- Bundle budgets see files; page budgets see requests; you need both.
Practice exercises
Exercise 1 (45 min): Run the lab's CLI on both stats files. Which lines change between them, and which budget caught the 48 KiB chart chunk?
Exercise 2 (2 hours): Write a budget file for a page you maintain from its current numbers (size by type, request counts, three metrics). Where did you set the warning band, and why?
Exercise 3 (2 hours): In DevTools, record a throttled load of a page you own. Find the longest task, open its call tree and bottom-up views, and name the function with the most self time.
Exercise 4 (half day): Extend the lab so a page in the stats file with no budget entry fails the run, and add a test with a renamed route. What would you do for pages that are intentionally unbudgeted?
Related reading
In this course:
- Loading Performance and Core Web Vitals: the metrics, lab versus field data and the budget table this lesson expands.
- Optimizing Largest Contentful Paint: what to profile when LCP fails its budget.
- Interaction to Next Paint and Responsiveness: the INP budget and its percentile arithmetic.
- The Event Loop and the Main Thread: why long tasks show up in the flame chart.
- Code Splitting, Lazy Loading and Tree Shaking: the usual fix for a script budget failure.
- Observability and SLOs: alerting on percentiles in production.
Official documentation
- web.dev: Performance budgets 101: the three kinds of budget, per-page-type limits, enforcement and tracking.
- web.dev: Use Lighthouse for performance budgets: the shape of a Lighthouse budget file.
- web.dev: Incorporate performance budgets into your build tools: webpack hints, size-limit tools and CI.
- web.dev: Web Vitals: the 2.5 s, 200 ms and 0.1 values and the 75th percentile.
- Chrome DevTools: Performance features reference: recording, throttling, the Main track and the call tree and bottom-up tables.
- Chrome DevTools: Analyze runtime performance: the profiling workflow and CPU throttling.