Performance budgets.
"Keep it fast" loses every argument with a feature, because it never says how fast or at whose expense. A budget is the same wish written as a number the build can check: this page may ship 150 KB of script, this route may take 2.5 seconds to show its main photo on the phone we design for. Once the number exists, a pull request that breaks it fails like a broken test, and the conversation moves from whether speed matters to what the new feature should replace.
Builds on Core Web Vitals as requirements and Designing for the device you don't own.
The idea.
A speed requirement only protects users if something checks it before release. A budget is that check, written down in units a build can measure.
Cobbleway sells bike parts at shop.cobbleway.example. Its riders shop from the trail head and the bus stop, so the team designs for a mid-range Android phone on a patchy 4G signal, and it has promised that the main photo on a product page appears within 2.5 seconds for three visits in four. That promise is the requirement. On its own it changes nothing, because no single change ever breaks it: a reviews widget adds a little, a new icon set a little more, and a year later the page takes four seconds and nobody can point at the commit that did it.
A budget fixes that by deciding the limits up front and checking them on every change. The team works out how many bytes fit inside the 2.5 seconds on the phone it designs for, divides those bytes between the parts of the page and the people who own them, and wires a check into continuous integration. A change that would spend more than its share now fails in review, while it is still one diff with one author, instead of surfacing months later as a slow page with a hundred suspects.
The point is not the number itself; it is that the number exists before the feature does. With a budget, adding something heavy becomes a trade the team makes on purpose: shrink something, remove something, or do not ship the new thing in that form.
Cobbleway's product page, 150 KB of script
- framework, width 45, Framework runtime, value 45, UI library and router; platform team
- shared, width 35, Shared app code, value 35, header, basket drawer, search box, data layer; web core team
- route, width 30, Product route chunk, value 30, gallery, variant picker, fitment checker; catalogue team
- 3rd party, width 25, Third-party tags, value 25, analytics and reviews widget; marketing owns this line
- headroom, width 15, Unspent headroom, value 15, kept unallocated for fixes and surprises
- 135 KB shipped today, from 0 to 135
- Framework runtime
- Shared app code
- Product route chunk
- Third-party tags
- Unspent headroom
- Over budget
- framework
- UI library and router; platform team
- shared
- header, basket drawer, search box, data layer; web core team
- route
- gallery, variant picker, fitment checker; catalogue team
- 3rd party
- analytics and reviews widget; marketing owns this line
- headroom
- kept unallocated for fixes and surprises
As it starts. 3 steps follow.
The same year, with and without a budget
How it works.
Users feel timings, but a build can only cheaply count things. So a budget usually starts as a timing, gets converted into bytes on the phone and network you design for, and is then checked as bytes on every change and as timings on the routes that matter.
Three kinds of budget
What a budget can limit
| Kind | Limits | Cobbleway example | Checked |
|---|---|---|---|
| Quality (what users feel) | A user-centred metric at a percentile of real visits: the Core Web Vitals, or a rule-based score such as Lighthouse performance | LCP ≤ 2.5 s, INP ≤ 200 ms, CLS ≤ 0.1 at p75 on mobile | In the field, after release; a score can also be asserted in the lab |
| Milestone timing | When a moment in the load happens on one fixed lab profile: LCP, First Contentful Paint, Total Blocking Time | lab LCP ≤ 2.5 s, TBT ≤ 200 ms on Slow 4G with a 4x slower CPU | In CI, against a preview build, a few runs per route |
| Quantity | How much the page asks for: compressed bytes by type, number of requests, number of third-party scripts, size of one chunk | ≤ 150 KB of JS per page, ≤ 30 KB per route chunk, ≤ 3 third-party scripts | On every build, in seconds, with no browser at all |
The three kinds sit in a chain. The quality target is the promise to users, and only field data from real visits can tell you whether you kept it (what each vital means and why p75 is covered in Core Web Vitals as requirements). A milestone timing is the same idea measured on one repeatable lab profile, so it can run before release. A quantity is the cheapest proxy of all: counting the bytes in a build takes seconds and gives the same answer every time, so it can run on every commit.
Each step down the chain is faster to check and further from what users feel. That is why a working budget has more than one kind: quantity limits catch most regressions instantly, timing limits in the lab catch the ones bytes miss (a script that is small but slow to run, a photo requested too late), and the field target tells you whether the lab profile still resembles your users.
Working backwards from 2.5 seconds
To turn a timing into bytes, fix the network you design for and subtract everything that happens before the critical bytes can flow, plus what happens after they arrive. Cobbleway uses Lighthouse's default mobile profile, Slow 4G, as the lab stand-in for its rider on patchy 4G (choosing that profile is the subject of Designing for the device you don't own). At 1.6 megabits a second the pipe moves about 200 KB a second, and every round trip costs 150 ms.
How many bytes fit before the main photo
- LCP target
- 2,500 msweb.dev "good" threshold, held at p75
- Round trip on the lab profile
- 150 msLighthouse Slow 4G
- Download throughput
- 200 KB/s1.6 Mbps ÷ 8, rounded
- Server time to start the HTML
- 100 msassumption; cached product data
- HTML document
- 20 KBassumption; compressed
- Reserve for style, decode and paint after the last critical byte
- 250 msassumption
- Other critical bytes: CSS 16, hero photo 70, one font 24
- 110 KBassumption; each has its own owner
- Connection: DNS, TCP and TLS 1.3, one round trip each3 × rtt450 msfrom Round trip on the lab profile
- First byte of HTMLconnect + rtt + server = 450 + 150 + 100700 msfrom Connection: DNS, TCP and TLS 1.3, one round trip each, Round trip on the lab profile and Server time to start the HTML
- HTML fully arrivedttfb + html ÷ bw = 700 + 100800 msfrom First byte of HTML, HTML document and Download throughput
- Subresource bytes start flowing (one more round trip to request them)html-done + rtt950 msfrom HTML fully arrived and Round trip on the lab profile
- Critical bytes must land bylcp − render = 2,500 − 2502,250 msfrom LCP target and Reserve for style, decode and paint after the last critical byte
- Time window for critical bytes2,250 − 9501,300 msfrom Critical bytes must land by and Subresource bytes start flowing (one more round trip to request them)
- Bytes that fit in the window1.3 s × 200 KB/s260 KBfrom Time window for critical bytes and Download throughput
- Left for compressed JavaScriptbytes − other = 260 − 110150 KBfrom Bytes that fit in the window and Other critical bytes: CSS 16, hero photo 70, one font 24
- Nearly half the 2.5 s is gone on overheads: 950 ms of round trips and HTML before the first CSS byte, and 250 ms of rendering at the end. Only 1.3 s is left for every critical byte, which is why the byte budget is so much smaller than it feels on office Wi-Fi.
- Every input is a lever. A server that answers in 300 ms instead of 100 costs 40 KB of script; a 140 KB hero photo instead of 70 costs 70 KB. The budget is one total that the page's parts trade against.
- This is a lab calculation for a typical visit on the reference profile, not the field p75. Real visits vary around it, so Cobbleway keeps 15 KB of the 150 unallocated as headroom.
The product page load on Slow 4G, against the 2.5 s line
Scenario 1 of 3: As described.
Timeline as a list
The product page load on Slow 4G, against the 2.5 s line: 7 lanes, from 0 ms to 3,200 ms.
- 0–450 ms · Connection (DNS, TCP, TLS) · 3 round trips
- 450–800 ms · HTML · 20 KB
- 700 ms · HTML · first byte
- 950–1,030 ms · CSS (16 KB) · 80 ms
- 950–2,250 ms · all lanes · window: 1.3 s window for critical bytes
- 1,030–1,705 ms · JavaScript · 135 KB · 675 ms
- 1,705–1,825 ms · Font (24 KB) · 120 ms
- 1,825–2,175 ms · Hero photo (70 KB) · 350 ms
- 2,175–2,425 ms · Style, decode, paint · 250 ms
- 2,425 ms · Style, decode, paint · LCP 2.43 s (ok, ok)
- 2,500 ms · all lanes · LCP target 2.5 s (deadline)
Which bytes, and the second bill
"150 KB of JavaScript" is ambiguous until you say which 150. The window above is about the network, so the budget counts compressed bytes, the ones that cross the wire. But tools disagree on what they count by default, and a limit copied from one tool into another can be off by a factor of three or four, because minified script usually shrinks to a fraction of its size under Brotli. Write the unit next to every limit.
Bytes on the wire are also only the first bill for script. After it arrives, the browser decompresses it, parses and compiles it, and runs it, mostly on the main thread that also handles taps; the V8 team names download and execution as the dominant costs, and a median phone runs the same code several times slower than a high-end one. Why a kilobyte of script costs far more than a kilobyte of photo is taught in Bundle size and what it costs; for a budget the consequence is simple: script gets its own byte limit, and a timing limit on main-thread work backs it up.
What each number measures
| Number | What it counts | Who reports it by default | Use it for |
|---|---|---|---|
| Transfer size | Bytes received over the network for a response, compressed, including headers | Lighthouse resource-summary, DevTools Network panel | Byte budgets derived from a network window |
| Compressed file size | The built file run through Brotli or gzip, measured without a browser | size-limit (Brotli unless told otherwise) | Fast per-chunk checks on every build |
| Emitted size | The minified file as the bundler writes it, before compression | webpack performance hints (250,000 bytes per asset and per entry point unless set) | A rough guard; convert before comparing with a wire budget |
| Main-thread time | Milliseconds the CPU spends parsing, compiling and running script during load | Lighthouse Total Blocking Time and bootup-time, on a throttled CPU | The second bill that bytes alone do not show |
Cobbleway's second bill, on the reference phone
Splitting the total
A single page-wide number tells you when you are over, not who should fix it. So the total is divided along the lines the code is already divided: the framework runtime every page loads, shared application code (header, basket, search), one chunk per route, and third-party tags. Each slice gets a limit and an owning team, and the slices plus a deliberate headroom add up to the total. A route that loads less shared code may spend more on its own chunk; the total per page is what the network window allows.
Third-party scripts belong inside the budget, not beside it. Analytics, a reviews widget or a chat bubble cross the same pipe and run on the same main thread as your own code; leaving them out makes the budget look healthy while the page is not. Because another company writes them, their slice is usually a count plus a byte ceiling, with marketing or whoever adds them as the owner.
Two levels per limit
A warning before the wall
| Level | Cobbleway product page JS | What happens |
|---|---|---|
| Warning | ≥ 140 KB | The check passes but comments on the pull request. The owning team plans a trim before it becomes urgent |
| Error | > 150 KB | The check fails and the pull request cannot merge until something is traded or an owner approves an exception |
In practice.
Cobbleway's budget as the team actually runs it: a limit per route and per slice, two checks in CI, and the pull request that tested it. Then how to say all of this in an interview.
Where the page's bytes come from
One product page, four owners
135 KB of script in total. The gallery, rotor picker and fitment checker are the catalogue team's 30 KB route chunk; the header and basket are shared code; the star rating comes from a third-party reviews widget.
As shipped. Store “Cobbleway”, showing the product view. Cart button “Basket” with badge 1 (its accessible name says “Cart, 1 item”, not the number alone). Product (showing) “Ridgeline hydraulic disc brake set” by “Front and rear, mineral oil”: rating 4.5 (“89 reviews”); price £149.00 (was £172.00, struck through) “Save £23”. Gallery: image 1 of 4 (“Brake caliper and lever, side view”); thumbnails beside it. Rotor size (tiles, a radio group): 160 mm, 180 mm (selected), 203 mm. Quantity 1 (stepper “Quantity”). Buttons: “Add to basket”. Delivery: “Free delivery Thursday”. Folded sections: Fits these bikes, Specs, Reviews.
- Route chunk: gallery and zoom (catalogue team)
- Route chunk: rotor picker and fitment check
- Third-party: reviews widget (marketing)
- Shared app code: header and search (web core)
- Shared app code: basket store and drawer
Cobbleway's budget per route (lab, Slow 4G, 4x CPU)
| Route | JS per page | Route chunk | Third-party | Lab LCP / TBT | Why it differs |
|---|---|---|---|---|---|
| / | ≤ 140 KB | ≤ 20 KB | ≤ 2 scripts, 20 KB | 2.5 s / 200 ms | Large promo photo as the LCP element takes more of the window |
| /c/:category | ≤ 150 KB | ≤ 30 KB | ≤ 2 scripts, 20 KB | 2.5 s / 200 ms | Filters and the product grid; thumbnails are small |
| /p/:product | ≤ 150 KB | ≤ 42 KB | ≤ 3 scripts, 25 KB | 2.5 s / 200 ms | Route chunk raised from 30 by rebalancing headroom after PR #2207 |
| /basket | ≤ 130 KB | ≤ 20 KB | ≤ 1 script, 10 KB | 2.5 s / 150 ms | Tight TBT: quantity steppers must answer at once |
| /checkout | ≤ 130 KB | ≤ 20 KB | payment SDK only, 30 KB | 2.5 s / 150 ms | No marketing tags; the payment provider's script is the only third party |
Enforcing it in CI
Two checks, at two speeds. The first runs size-limit against the built chunks on every push: no browser, a few seconds, and it names the chunk that grew. The second deploys a preview, loads each key route with Lighthouse CI on the mobile profile several times, and asserts on transfer bytes, the number of third-party requests and the lab timings. Byte checks catch most regressions early and cheaply; the timing check catches what bytes cannot, such as a small script that runs for a long time, or a photo that is now requested late.
Lighthouse 12 removed its own budget.json, so limits that used to live there now go into Lighthouse CI assertions. An error-level assertion makes the run exit non-zero and fails the status check; warn-level ones are printed and let the build through, which is how Cobbleway implements its two levels. How many runs to take, which run an assertion judges, and how to keep a shared runner from producing false alarms belong to Lab data, field data and releases.
Cobbleway's budget, as configuration
[
{ "name": "framework", "path": "dist/js/framework-*.js", "limit": "45 kB" },
{ "name": "shared app", "path": "dist/js/app-*.js", "limit": "35 kB" },
{ "name": "route: product", "path": "dist/js/route-product-*.js", "limit": "42 kB" },
{ "name": "route: category", "path": "dist/js/route-category-*.js", "limit": "30 kB" },
{ "name": "route: checkout", "path": "dist/js/route-checkout-*.js", "limit": "20 kB" },
{
"name": "product page, first-party total",
"path": ["dist/js/framework-*.js", "dist/js/app-*.js", "dist/js/route-product-*.js"],
"limit": "125 kB"
}
]
{
"ci": {
"collect": {
"url": [
"https://preview.cobbleway.example/p/ridgeline-hydraulic-brake-set",
"https://preview.cobbleway.example/checkout"
],
"numberOfRuns": 5
},
"assert": {
"assertMatrix": [
{
"matchingUrlPattern": ".*/p/.*",
"assertions": {
"resource-summary:script:size": ["error", { "maxNumericValue": 150000 }],
"resource-summary:third-party:count": ["error", { "maxNumericValue": 8 }],
"largest-contentful-paint": ["error", { "maxNumericValue": 2500, "aggregationMethod": "median" }],
"total-blocking-time": ["error", { "maxNumericValue": 200, "aggregationMethod": "median" }],
"categories:performance": ["warn", { "minScore": 0.9, "aggregationMethod": "median" }]
}
},
{
"matchingUrlPattern": ".*/checkout$",
"assertions": {
"resource-summary:script:size": ["error", { "maxNumericValue": 130000 }],
"resource-summary:third-party:count": ["error", { "maxNumericValue": 4 }],
"total-blocking-time": ["error", { "maxNumericValue": 150, "aggregationMethod": "median" }]
}
}
]
}
}
}
A few details make this configuration honest. The size-limit total covers only first-party chunks, because third-party scripts are not in the build; Lighthouse sees them on the loaded page, so the page-wide script total and the third-party requests are asserted there. That count covers every request Lighthouse attributes to a third party, not only script files: Cobbleway's three product-page tags make eight requests between them on a typical load (scripts, a stylesheet, beacons), so the assertion caps eight requests, and checkout's payment SDK makes four. Lighthouse CI wants bytes, so 150 KB becomes 150,000 and 130 KB becomes 130,000. That matches size-limit, which reads "kB" as 1,000 bytes ("KiB" would be 1,024); pick one convention and write it in the file. And the warn level on the overall score is deliberate: a score blends several metrics and moves for reasons nobody can act on, so it informs rather than blocks.
What happens to pull request #2207
- Catalogue team → CI build: push: add carousel to product route
- CI build → size-limit: built chunks
- size-limit → Catalogue team (reply): route: product 58 kB of 30 · FAIL (seconds)
- CI build → Lighthouse CI (preview): preview URLs, 5 runs each
- Lighthouse CI (preview) → Catalogue team (reply): script 163 KB of 150 · LCP median 2.57 s · FAIL
- Note over Catalogue team: options: optimise, remove, or don't add
- Catalogue team → CI build: push: drop zoom widget, defer swipe code
- size-limit → Catalogue team (reply): route: product 42 kB of 30 · FAIL; first-party total 122 of 125 · PASS
- Lighthouse CI (preview) → Catalogue team (reply): script 147 KB of 150 · LCP median 2.49 s · PASS
- Catalogue team → Budget owner (web core): request: move 12 KB of headroom into the product route
- Budget owner (web core) → Catalogue team (reply): approved: route limit 30 → 42 kB, page total stays 150
- Catalogue team → size-limit: push: .size-limit.json updated in the same PR
- size-limit → Catalogue team (reply): all chunks PASS · ready to merge
- Pro:The page total stays at 150 KB, so the LCP promise is untouched
- Pro:The cost is paid by the feature that caused it: the zoom widget it replaces goes, and swipe code waits for a swipe
- Pro:The headroom move is visible, reviewed and owned
- Con:Only 3 KB of headroom left for the next surprise
- Con:Deferred swipe code means the first swipe waits for a small download
The 2.5 s derivation no longer holds; the lab LCP is about 2.57 s; Sets the precedent that every approved feature raises the limit
Leaves a real product improvement on the table; budgets are meant to force a trade, not to freeze the page
Saying it in an interview
Where the systems' flows spend a budget
| Flow | What competes for the budget | How the budget shapes it |
|---|---|---|
| amazon/product-detail-page | Gallery, variant picker, reviews, recommendations and third-party tags | Buy box and gallery inside the first-load limit; reviews and recommendations as lazy chunks with their own limits; tags counted on the page |
| amazon/product-listing | Filters, a grid of thumbnails, sort and pagination code | Many small images share the window with script, so the byte budget is split between images and JS, not set for JS alone |
| airbnb/listing-page | Photo tour, map, date picker, booking card | Map and date picker load on intent; the booking card's script is in the route chunk because it must answer quickly |
| netflix/home-rows | Row components, artwork, autoplay previews | A tight first-load limit for the shell and first rows; preview playback code outside it |
| facebook/news-feed | Feed renderer, composer, reactions, ads | Composer and rich reactions as lazy chunks; ad scripts as a third-party slice with a count limit |
Trade-offs.
A budget is a social contract as much as a config file. Set it too tight and people route around it; never revisit it and it quietly stops meaning anything.
The life of one budget line
One limit, such as the product route chunk. Notice the two ways out of a breach (trade or a time-boxed exception) and the self-loop that ratchets the limit down after a win.
| From → To | Event | Guard | Action | Actor |
|---|---|---|---|---|
| Proposed → Enforced | team signs off | add error-level check | web core | |
| Enforced → Enforced | merge leaves > 10 KB slack | lower limit | budget owner | |
| Enforced → Near the limit | size crosses warn level | comment on PR | CI | |
| Near the limit → Enforced | a PR trims the slice | owning team | ||
| Near the limit → Breached | PR exceeds the limit | fail status check | CI | |
| Enforced → Breached | PR exceeds the limit | fail status check | CI | |
| Breached → Enforced | PR removes or defers bytes | PR author | ||
| Breached → Temporary exception | owner approves | expiry set | raise limit | budget owner |
| Temporary exception → Enforced | expiry date passes | restore limit | CI | |
| Enforced → Retired | route deleted | remove check |
- Proposedstart
- Derived from the timing target; not yet enforced
- Enforced
- Error-level check on every build
- Near the limit
- Warn level crossed; still merging
- Breachederror
- A pull request fails the check
- Temporary exception
- Raised limit with an owner and an expiry date
- Retiredend
- The route or chunk no longer exists
The same saving, with and without a ratchet
- Fixed limit
- No ratchet
- Ratcheted limit
- With ratchet
Data
| Release | Fixed limit (KB) | No ratchet (KB) | Ratcheted limit (KB) | With ratchet (KB) |
|---|---|---|---|---|
| 1 | 150 | 147 | 150 | 147 |
| 2 | 150 | 146 | 150 | 146 |
| 3 | 150 | 127 | 150 | 127 |
| 4 | 150 | 131 | 135 | 129 |
| 5 | 150 | 135 | 135 | 131 |
| 6 | 150 | 138 | 135 | 132 |
| 7 | 150 | 141 | 135 | 129 |
| 8 | 150 | 143 | 135 | 131 |
| 9 | 150 | 145 | 135 | 132 |
| 10 | 150 | 147 | 135 | 130 |
| 11 | 150 | 148 | 135 | 132 |
| 12 | 150 | 149 | 135 | 131 |
- At 3: polyfill and icon-set trim
- At 4: limit lowered to 135
- Pro:Byte checks are instant and deterministic, so they can block without false alarms
- Pro:Lab timings catch slow-but-small code and late-requested images
- Pro:The field tells you whether the lab profile still matches your users
- Con:Three systems to keep running and in agreement
- Con:Byte limits are proxies that must be re-derived when the network profile or page shape changes
Blind to expensive code: a 5 KB script that runs for 300 ms passes; Blind to ordering, such as a hero photo discovered late
Noisy from run to run, so tight limits cause flaky failures; Slow, and the failure says what got worse but not which chunk caused it
How budgets go wrong
| Failure | Impact | Detection | Mitigation | Meanwhile |
|---|---|---|---|---|
| Limits set so tight that ordinary work fails | Teams learn to bypass the check, mark code as lazy that loads immediately anyway, or disable the job | Exceptions requested every sprint; checks skipped or muted in CI history | Start from today's measured size plus a small margin and ratchet down, rather than starting from an ideal number | The page is no worse than before the budget existed |
| Exceptions that never expire | The limit drifts upward one approval at a time until it no longer protects the 2.5 s promise | Compare the current limit with the derived one at each quarterly review | Every raise carries an owner and an expiry date in the config, and the check fails when the date passes | Users get slower pages with no single change to blame |
| Third-party tags added outside the build | A tag manager injects a new script in production; no pull request ever sees it | A scheduled Lighthouse CI run against production asserting the third-party request count and script total | Give marketing a third-party slice with a count limit and an allowlist in the tag manager | The page pays for the tag until the scheduled run flags it |
| Budget measured in the wrong unit | A wire budget copied into a tool that counts uncompressed bytes is several times too strict, or the reverse lets bloat through | A size that passes one tool and fails another | Write the unit and the compression next to every limit, and derive each tool's number from one source | Either needless failures or a gate that does not bite |
| Only the home page is budgeted | Product and checkout pages, where money is made, grow unchecked | Field p75 by route shows the unbudgeted routes worst | Budget every route template that matters to revenue, not just the landing page | Regressions found by users instead of CI |