Performance budgets and monitoring
Agreed limits on what a page may cost (kilobytes of JavaScript, LCP, INP) that CI checks on every pull request and real-user monitoring watches in production, so speed stops getting worse one small change at a time.
How fast sites get slow
Picture a team that ships a new product page in January. It is fast: 180 KB of compressed JavaScript, an LCP (Largest Contentful Paint, the moment the biggest image or text block appears) of 1.9 seconds on a mid-range phone. Everyone is proud. Nobody writes the numbers down anywhere a machine can check them.
Over the next year, about 400 pull requests land. One adds a date library for a countdown timer (+24 KB), another an analytics tag (+38 KB), another a carousel (+31 KB), and another swaps a hand-written dropdown for a component library (+60 KB). Each change is reasonable. Each review asks "does it work?" and nobody asks "what does it cost?".
By December the page ships 520 KB of JavaScript and LCP on the same phone is 3.6 seconds. Conversion is down, and nobody can name the change that caused it, because no single change did. This is the normal way sites get slow: not one big mistake, but a hundred small ones that each looked free.
What a performance budget is
A performance budget is a limit the team agrees on before the work starts: "the product page ships at most 250 KB of compressed JavaScript", or "LCP at the 75th percentile on phones stays under 2.5 seconds". Then a machine checks the limit on every change, and a dashboard watches it in production.
The limit itself is the easy part. The value comes from three things around it: the number is written down where a tool can read it, a tool fails the build or raises an alert when the number is crossed, and someone owns the decision about what happens next. A budget that only lives in a slide deck is a wish, not a budget.
Budgets and monitoring are two halves
Budgets and monitoring answer different questions, and you need both.
- Budgets in CI answer "is this change about to make things worse?". They run before merge, in a controlled lab, and they are fast and repeatable. They catch the date library on the day it is added.
- Monitoring in production answers "are real users actually having a good time?". It collects timings from real browsers on real networks. It catches what the lab misses: a slow third-party script, a CDN problem, a new device mix, a slow API.
A team with only CI checks can pass every build while real users suffer from things the lab never sees. A team with only monitoring sees the damage a week after it shipped, then spends days hunting for the cause. Together they form a loop: field data tells you which limits matter, CI keeps you inside them, and the field confirms it worked.
Why this comes up in design interviews
In a frontend system design interview, saying "we'll make it fast" earns nothing. Saying "the initial route gets a 170 KB JavaScript budget enforced by size-limit in CI, Lighthouse CI asserts LCP under 2.5 seconds on a throttled run, and RUM alerts us if p75 INP on Android rises more than 20 percent" shows you know how speed survives a team of 30 engineers. That is what this lesson builds, step by step.
Check your understanding · Exercise 1
A page went from 180 KB to 520 KB of JavaScript in a year with no single large change. Which statements are true? Select all that apply.
The mental model: a spending limit with a bank statement
Think of a household budget. Saying "we should spend less" changes nothing. Saying "groceries get 400 a month" changes behaviour, because every purchase can be checked against a number. The bank statement at the end of the month shows whether the plan held in real life.
Performance works the same way. The budget is the spending limit, checked at the till (CI, on every pull request). Monitoring is the bank statement (real-user data, after release). And the currency you spend is the user's time, paid for in bytes, requests, and main-thread work.
Three kinds of budget
Budgets come in three kinds. They differ in how close they are to what users feel and in how easy they are to check. A good set mixes all three.
- Quantity budgets limit what you send: kilobytes of compressed JavaScript, CSS, images, and fonts; the number of requests; the number of third-party origins. Example: "the home route ships at most 170 KB of compressed JavaScript". They are exact, cheap to measure on every commit, and give the same answer every time.
- Milestone budgets (also called timing budgets) limit when things happen: LCP, INP (Interaction to Next Paint, the delay between a tap and the next frame), CLS (Cumulative Layout Shift, how much content jumps), TTFB (Time to First Byte), or TBT (Total Blocking Time, a lab stand-in for INP). Example: "p75 LCP on phones stays at or below 2.5 seconds". They measure what users actually feel, but they are noisy.
- Rule-based budgets limit a score from a tool, usually the Lighthouse performance score. Example: "the Lighthouse performance score stays at or above 90 on the product page". They are easy to explain to non-engineers, but a score hides which part got worse.
Quantities are causes, milestones are effects
The three kinds are not rivals; they sit on a chain of cause and effect. Bytes and requests are causes, and load timings are effects. A score is a summary of effects. When LCP gets worse, the cause is almost always a quantity that grew: a bigger hero image, more render-blocking CSS, more JavaScript before the first paint, or a slower server.
That chain gives you the strategy. Use milestone budgets as the goal, because they are what users feel and what Google's Core Web Vitals measure. Use quantity budgets as the guard rails, because they are precise enough to fail a single pull request without false alarms. Use a score as a summary for dashboards and managers, never as the only check.
| Question | Quantity | Milestone | Rule-based |
|---|---|---|---|
| Example | JS at most 170 KB gzip | p75 LCP at most 2.5 s | Lighthouse score at least 90 |
| Close to what users feel? | Indirect | Yes | Partly |
| Noise between runs | None | 10 to 20 percent in the lab | A few points |
| Where you check it | CI, every commit | Lab in CI and field with RUM | Lab in CI |
| Points to a cause? | Yes, the file that grew | Only with attribution | No |
Budget per page type, in compressed bytes
Two details trip people up. First, a budget belongs to a page type or route, not the whole site. A marketing landing page, a search results page, and a logged-in editor do different jobs on different devices, so they get different limits. A single site-wide number is either too loose for the landing page or impossible for the editor.
Second, state the unit. Network cost depends on compressed bytes (gzip or Brotli, what travels on the wire). CPU cost depends on uncompressed bytes, because the browser parses and compiles the unzipped text, which is often 3 to 4 times larger. Most teams budget compressed JavaScript and keep in mind that 170 KB compressed is roughly 600 KB of code the phone must parse.
Check your understanding · Exercise 2
Which of these are quantity budgets? Select all that apply.
Setting the numbers
A budget that is too loose never fails, so it protects nothing. A budget that is too tight fails on every pull request, so people learn to ignore it or switch it off. The craft is in picking numbers that are honest about today and push gently toward a goal. There are three sources for those numbers, and good teams use all three.
Start from your baseline
Measure where you are today: the compressed size of each route's JavaScript, CSS, and images from the build, and the p75 LCP, INP, and CLS per page type from field data. Then set each budget a little above today's value. If the home route ships 162 KB of JavaScript, a budget of 170 KB lets routine changes through but fails the pull request that adds a 30 KB library.
This is a ratchet: the budget says "no worse than today". Every time someone makes the page smaller, you lower the budget to the new size plus the same small headroom. The limit only moves one way. Over a few quarters the ratchet turns a 520 KB bundle back into a 300 KB one without a big rewrite, because every win is locked in.
Compare with competitors
Your users do not compare you with an ideal. They compare you with the other tab. The Chrome UX Report (CrUX), Google's public dataset of field timings from opted-in Chrome users, gives the p75 LCP, INP, and CLS for most popular origins. PageSpeed Insights shows it for any URL or origin with enough traffic, and the CrUX API and BigQuery dataset let you pull it for a list of competitors.
A common rule of thumb is to aim to be about 20 percent faster than your fastest competitor, because smaller differences are hard for people to notice. Treat that 20 percent as a starting point, not a law. The real power of the comparison is social: "our main rival's product page paints 900 ms sooner on phones" is a goal a product manager understands and will fund.
Work backwards from the goal
The third source is arithmetic. Pick the milestone you want (say LCP at 2.5 seconds) on the device and network you care about (say the Lighthouse mobile profile: 150 ms round trip, 1.6 Mbps, a CPU 4 times slower than a laptop). Then subtract everything that must happen before LCP, and see how many kilobytes of JavaScript fit in what is left.
- Connection and HTML. DNS, TCP, and TLS take about 3 round trips (450 ms), the request one more (150 ms), and the server about 200 ms. TTFB is about 0.8 seconds.
- Critical CSS and the hero image. 20 KB of CSS is about 0.1 seconds at 1.6 Mbps; a 100 KB hero image is about 0.5 seconds. Now we are at about 1.4 seconds.
- What is left for JavaScript. About 1.1 seconds, minus some slack for rendering: call it 1.0 second.
- Each compressed KB of JavaScript costs about 8.5 ms here: 5 ms to download (8 kilobits at 1.6 Mbps) and about 3.5 ms to parse and run (3.5 KB unzipped at about 1 second per MB).
- Budget: 1,000 ms divided by 8.5 ms is about 120 KB of compressed JavaScript before LCP, for a page whose LCP waits for JavaScript (a client-rendered page).
This is rough: downloads overlap, and the 1 second per MB parsing rule is right within a factor of two. But the order of magnitude is what matters. It explains why a client-rendered page with 500 KB of JavaScript cannot hit 2.5 seconds on a mid-range phone, no matter how well it is tuned. Try the calculator below with your own numbers.
For a whole-page ceiling, Alex Russell's yearly "Performance Inequality Gap" analysis is a useful outside check: for 2024 it suggested about 365 KB of compressed JavaScript at most for a JavaScript-heavy page, with HTML, CSS, images, and fonts sharing the rest of a roughly 730 KB total, so the page can be interactive within about 3 seconds on 75th percentile devices and networks. Our lesson on estimation walks through the same arithmetic.
Decision Where should the first set of budget numbers come from?
What we pick: Use all three, for different jobs. Lock the baseline as the CI budget on day one so nothing gets worse. Use the competitor and worked-backwards numbers as the target, and lower the ratchet toward it a step at a time, for example 10 percent per quarter.
Check your understanding · Exercise 3
The search route ships 210 KB of JavaScript today. Your worked-backwards target is 140 KB. What is a sensible first CI budget? Pick one.
Enforcing budgets in CI
A budget becomes real when a machine checks it on every pull request and the check can turn the build red. Most teams run two kinds of check, at two different speeds: a size check that reads the build output in a few seconds, and a lab check that loads real pages in a throttled browser in a minute or two.
Size checks: size-limit and friends
A size check builds the app, finds the files for each route or entry point, compresses them the way your server will, and compares the result with a limit. It is exact: the same commit always gives the same number, so it never fails at random. That makes it the best tool for blocking a merge.
size-limit is a popular open-source tool for this. You list files (globs work) and a limit for each, and it fails with a clear message when one is over. It compresses with Brotli by default; "gzip": true switches an entry to gzip, so the number matches a budget written in gzip KB.
A companion GitHub Action posts a comment on the pull request with the size before and after, so the author sees "+31.2 KB" next to their change. With the webpack or esbuild plugin installed, its --why flag opens a report of what is inside the bundle.
[
{
"name": "home route (initial JS)",
"path": ["dist/assets/entry-*.js", "dist/assets/home-*.js"],
"limit": "170 kB",
"gzip": true
},
{
"name": "checkout route",
"path": "dist/assets/checkout-*.js",
"limit": "60 kB",
"gzip": true
},
{
"name": "all CSS",
"path": "dist/assets/*.css",
"limit": "40 kB",
"gzip": true
}
]
.size-limit.json: one entry per route that matters, each with its own gzip limit (leave out gzip to measure Brotli, the default). Run it with npx size-limit in CI after the build.Other tools do the same job. The older bundlesize package works the same way but sees little maintenance today. Several bundlers and frameworks also have a check built in, and it is worth knowing their quirks.
- webpack performance hints. The
performanceoption warns or errors when an asset or entry point is overmaxAssetSizeormaxEntrypointSize(both 250,000 bytes by default). In production mode it only warns; sethints: "error"to fail the build. Note that webpack measures the minified file, not the compressed size, so 250 KB there is roughly 70 to 90 KB gzip. - Angular CLI budgets.
angular.jsonhas abudgetsarray per build configuration, with types such asinitial,anyComponentStyle, andbundle, each withmaximumWarningandmaximumError. New projects come with budgets already set: the production build warns when the initial bundle passes 500 kB and fails at 1 MB, measured uncompressed. Keep them and tighten them. - Next.js build output. Up to Next.js 15,
next buildprinted a "First Load JS" size for each route; Next.js 16 removed these size columns because they were inaccurate with React Server Components. The output never failed the build, so teams pair Next.js with size-limit, Lighthouse CI, or a script that reads the build output.
Lab checks: Lighthouse CI
Bytes are a cause, not the effect. To check milestones before release, Lighthouse CI (the @lhci/cli package) starts your built app, loads a list of URLs in headless Chrome with a throttled phone profile, runs Lighthouse several times per URL (3 by default), and checks the results against assertions. An assertion names a Lighthouse audit or category and a limit, at level warn or error.
{
"ci": {
"collect": {
"url": ["http://localhost:3000/", "http://localhost:3000/p/42"],
"numberOfRuns": 5
},
"assert": {
"assertions": {
"largest-contentful-paint": ["error", { "maxNumericValue": 2500, "aggregationMethod": "median" }],
"total-blocking-time": ["error", { "maxNumericValue": 200, "aggregationMethod": "median" }],
"cumulative-layout-shift": ["error", { "maxNumericValue": 0.1, "aggregationMethod": "median" }],
"resource-summary:script:size": ["error", { "maxNumericValue": 180000 }],
"resource-summary:third-party:count": ["warn", { "maxNumericValue": 8 }],
"categories:performance": ["warn", { "minScore": 0.9 }]
}
},
"upload": { "target": "temporary-public-storage" }
}
}
lighthouserc.json mixing all three kinds of budget: milestones (LCP, TBT, CLS) and script bytes fail the build, while the third-party count and the score only warn.Read the file closely. The milestone and script-size assertions are error, so they block the merge. The third-party count and the score are warn: a new tag deserves a look, and a score can drop for reasons you cannot act on.
numberOfRuns is 5 and each timing uses the median of the runs, because a single lab run can swing by 10 to 20 percent. Set aggregationMethod on purpose: without it, Lighthouse CI checks the most optimistic run, which hides regressions. The upload step stores the reports so a reviewer can click through to the full Lighthouse report for the failing run.
Keeping lab checks honest
- Test production builds. Development builds are bigger and slower; a budget checked against them means nothing.
- Fix the environment. Shared CI runners vary in speed. Lighthouse's default simulated throttling smooths some of that out, but a dedicated runner or a hosted service gives steadier numbers.
- Run several times and take the median. Three runs is the minimum; five is better for pages near the limit.
- Stub what you do not control. Slow third-party tags and test APIs add noise. Either block them in the lab and budget them separately, or keep them and accept wider limits.
- Compare with the base branch. A check that says "LCP rose from 2.1 to 2.4 seconds compared with main" is more useful than one that only says "under 2.5 seconds".
Decision Which check should block a merge?
What we pick: Block merges on size checks and on Lighthouse milestone assertions with a median of 3 to 5 runs. Show the score as a warning and a trend, never as the only gate.
Check your understanding · Exercise 4
webpack reports the main entry point at 240 KB and passes its default 250,000-byte hint. Your budget is 170 KB gzip. What do you know? Select all that apply.
Lab budgets and field budgets
A Lighthouse run in CI is lab data: one synthetic phone, one synthetic network, one empty cache, no user. Field data is what real users experience: thousands of devices, flaky networks, warm and cold caches, ad blockers, and actual taps. The two disagree all the time, and both are right about what they measure.
| Question | Lab (Lighthouse CI) | Field (RUM, CrUX) |
|---|---|---|
| When you get it | Before merge, in minutes | After release, hours to days |
| Devices and networks | One fixed profile | Everything your users have |
| INP | Not measured (TBT as a stand-in) | Measured from real taps and keys |
| Noise | Low with several runs | High per visit, low at p75 over many visits |
| Good for | Blocking regressions, debugging | Knowing the truth, setting priorities |
| Budget form | Median of N runs under a limit | p75 per segment under a limit |
Two sets of numbers for the same goal
Because the two disagree, give each its own budget. A field budget is the goal users feel: "p75 LCP on phones for the product page at or below 2.5 seconds". A lab budget is the guard you can run before merge: "median Lighthouse LCP for the product page at or below 2.8 seconds on the default mobile profile". The lab number is tuned so that pages which pass it tend to pass in the field.
You calibrate that relation from history. If, over the last six months, your lab LCP has run about 300 ms slower than your field p75 (common for sites with a fast CDN and many returning visitors, whose caches are warm), set the lab budget about 300 ms above the field goal. If the lab is faster than the field (common when real users have slower phones than the lab profile), set it lower. Recheck the gap every quarter.
Collecting field data with web-vitals
RUM (real-user monitoring) means a small script in your page measures each visit and sends the numbers home. Google's open-source web-vitals library is the usual way to measure LCP, INP, CLS, FCP, and TTFB the same way Chrome and CrUX do. It is about 2 KB compressed, and each onX function calls you back when that metric's value is ready to report, which for INP and CLS is usually when the page is hidden.
import { onCLS, onINP, onLCP, type Metric } from "web-vitals";
const ctx = {
release: __RELEASE_ID__, // set at build time, for regression tracking
route: routeTemplate(location.pathname), // "/p/:id", not "/p/42"
mem: (navigator as any).deviceMemory ?? null, // Chromium only
net: (navigator as any).connection?.effectiveType ?? null, // Chromium only
};
const queue: object[] = [];
function add(m: Metric) {
queue.push({ name: m.name, value: m.value, rating: m.rating, id: m.id, ...ctx });
}
function flush() {
if (queue.length === 0) return;
navigator.sendBeacon("/rum", JSON.stringify(queue.splice(0)));
}
onLCP(add);
onINP(add);
onCLS(add);
addEventListener("visibilitychange", () => {
if (document.visibilityState === "hidden") flush();
});
sendBeacon when the page is hidden.Three details in that code make budgets possible later. The release lets you compare one deploy with the previous one, and the route template (/p/:id) groups all product pages into one budget instead of a million URLs. And the device and network hints let you segment, which the next chapter is about. Our lesson on real-user monitoring covers the rest of the pipeline: sampling, storage, attribution, and single-page-app navigations.
visibilitychange with sendBeacon, not on load.CrUX as an outside check
Your own RUM is the source of truth for budgets, because you control the segments and see changes within hours. CrUX is still worth watching, for two reasons. It is what Google uses for the Core Web Vitals signal in search, and it lets you compare with competitors. Remember that CrUX reports a rolling 28-day window, so a regression on Monday shows up there slowly over the next four weeks, and a fix takes just as long to appear.
Check your understanding · Exercise 5
Lighthouse CI shows the product page at a median LCP of 1.8 seconds, but RUM shows p75 LCP on phones at 3.4 seconds. Which explanations are plausible? Select all that apply.
Segmenting by device and network
One p75 for the whole site is a comforting number and a misleading one. Suppose 60 percent of your visits come from fast laptops and new iPhones. Their LCP is excellent, so the overall p75 passes. Meanwhile the 20 percent of visits from low-end Android phones on slow networks fail badly, and the overall number cannot show it, because those visits sit above the 75th percentile line.
Those slow segments are often the people the business most wants to grow: new markets, mobile-first users, first-time visitors. So field budgets should be set and checked per segment, not only overall. Try the explorer below: it holds one fixed set of visits and lets you group them different ways.
Which dimensions to split by
- Device class. The biggest single factor for JavaScript cost. In Chromium you can read
navigator.deviceMemory(rounded RAM in GB) andnavigator.hardwareConcurrency(CPU cores) and bucket them into low, mid, and high. Safari and Firefox do not expose memory, so fall back to the user agent (for example, iPhone models) or to an "unknown" bucket. - Network.
navigator.connection.effectiveTypereports4g,3g,2g, orslow-2gfrom measured speed, in Chromium only. Server-side, the measured TTFB and the country are rough stand-ins. - Page type and route. Budgets belong to page types, so the data must be grouped by route template.
- Navigation type. First visits (cold cache), repeat visits (warm cache), back and forward cache restores, and prerendered pages have very different LCP. The
navigationTypefield on eachweb-vitalsmetric tells you which. - Country or region. Captures network quality, distance to your servers, and typical devices in one dimension.
- Browser. Chromium-based browsers report all three vitals. Firefox and Safari added LCP and INP recently (Baseline since December 2025), but CLS is still Chromium-only, so check which browsers feed each metric before you compare segments.
Budgets per segment
You do not need a budget for every combination. Three to six well-chosen segments cover most of the risk. A typical set for a consumer site: phones overall, low-end phones, desktop, and one or two key markets. Each gets the same goal (for example, the Core Web Vitals thresholds) but is checked separately, so a failure in one segment cannot hide behind a pass in another.
| Segment | Share of visits | p75 LCP | Budget 2.5 s | p75 INP | Budget 200 ms |
|---|---|---|---|---|---|
| Desktop | 35% | 1.4 s | Pass | 80 ms | Pass |
| Phones, high-end | 30% | 1.9 s | Pass | 120 ms | Pass |
| Phones, mid-range | 22% | 2.8 s | Fail | 240 ms | Fail |
| Phones, low-end | 13% | 4.6 s | Fail | 520 ms | Fail |
| All visits | 100% | 2.3 s | Pass | 170 ms | Pass |
The mix-shift trap
Segmenting also protects you from a subtle false alarm. Suppose a marketing campaign in a market with mostly low-end phones more than doubles their share of visits for a week. The overall p75 LCP rises by about 600 ms (2.3 to 2.9 seconds in the explorer above), an alert fires, and engineers start hunting for a bad deploy. But inside every segment nothing changed; only the mix of visitors moved.
The reverse happens too: a site can get slower in every segment while the overall number improves, because a fast segment grew. The fix is the same in both cases. Alert and judge on per-segment p75 values, and treat the overall number as a summary, never as the thing you defend.
Enough data per segment
Each segment needs enough visits for a stable p75. A few hundred samples per segment per day gives a usable daily number; a few thousand per hour gives a usable hourly number. Small segments need longer windows, so a niche market might only get a weekly check. Splitting by too many dimensions at once (device by network by country by route) leaves most cells nearly empty and every chart noisy.
Check your understanding · Exercise 6
Overall p75 LCP rose from 2.2 to 2.6 seconds this week. Per-segment p75 values are unchanged, and traffic from low-end phones went from 10 to 25 percent of visits. What should you do? Select all that apply.
Catching regressions
A regression is a change that makes a metric worse. Budgets catch the regressions that cross a line. But a page can get 15 percent slower and still be under budget, and in the field a slowdown can come from things no pull request touched. So teams watch for regressions at three moments: on each pull request, on each release, and continuously in production.
On each pull request: show the diff
The cheapest moment to catch a regression is before merge, when the author still remembers why they made the change. Size checks and Lighthouse CI already compare with a limit; the extra step is to compare with the base branch and post the difference as a comment. "Home route JS: 162.4 KB to 193.6 KB (+31.2 KB, +19 percent)" starts a conversation even when the result is still under budget.
Many teams add a second, softer rule next to the hard budget: any single pull request that grows a route by more than, say, 5 KB or 5 percent needs a reviewer from the performance owners to approve. This catches the slow drift long before the budget is hit, without blocking anyone.
On each release: compare cohorts
Because every RUM event carries a release ID, you can compare the new release's p75 with the previous release's p75 over the same hours. Better still, roll out the release gradually: send 5 percent of traffic to it, compare that group with the 95 percent still on the old release, and only go to 100 percent if the difference is within your tolerance. Comparing two groups at the same time removes most of the noise from time of day, traffic mix, and third-party outages.
In production: alert on p75, with care for noise
Field data is noisy. p75 LCP for one route can move 5 to 10 percent between hours just from traffic mix, and a naive "alert when p75 is over 2.5 seconds" rule pages someone every evening when phone traffic peaks. Good alert rules have four parts.
- A per-segment p75, not the overall number, so mix shifts do not fire alerts.
- A relative threshold against a matched baseline: for example, p75 more than 15 percent above the same hour last week, which cancels out daily and weekly cycles.
- A duration: the condition must hold for, say, 2 hours (or 3 checks in a row), so one bad hour from a network blip does not page anyone.
- A minimum sample count: no alert from a segment with fewer than, say, 500 samples in the window, because its p75 is not stable yet.
Add hysteresis: an alert that fires at +15 percent should only clear when the value is back under, say, +8 percent. Without that gap, a metric hovering around the threshold flips the alert on and off all day. The state machine below shows the full life of an alert.
Regressions without a deploy
Some of the worst field regressions have no matching commit. A marketing team adds a script through a tag manager, a third-party chat widget ships a heavier version, a CDN configuration change lowers the cache hit rate, or a backend change makes the HTML 300 ms slower. Your code did not change, so CI was green.
Two habits help. First, put configuration changes (tag manager publishes, feature flags, CDN rules) on the same timeline as deploys, so a step in the chart lines up with something. Second, budget third parties too: count third-party origins and requests in RUM (the Resource Timing API lists each request, though other origins hide their byte sizes unless they send a Timing-Allow-Origin header) and alert when they grow, the same way you do for your own code.
Check your understanding · Exercise 7
Which alert rules are well designed for field p75 LCP on the product page? Select all that apply.
When a budget breaks: ownership and the lab
Budgets fail socially more often than technically. A feature is due on Friday, the size check is red, and someone edits the limit from 170 KB to 200 KB in the same pull request, and nobody notices. Three months later the limit is 260 KB and the budget protects nothing. The technical setup is a day of work; the habits around it decide whether it lasts.
Who owns a budget
- Each budget has a named owner, usually the team that owns the route. The home page budget belongs to the home team, not to a central performance group that cannot ship their features.
- The budget file is protected. Put
.size-limit.jsonandlighthouserc.jsonunder a code-owners rule so that raising a limit needs approval from the owner or a performance champion. Lowering it can be self-approved. - Exceptions expire. If the business decides a feature is worth going over budget, record it with an owner and a date ("+20 KB until 30 June, while we replace the chart library"). A limit raised "for now" without a date is raised forever.
- Budgets are reviewed on a schedule, for example each quarter, against the field data: lower the ratchet where pages got faster, and revisit budgets that are always red or never close.
What to do when a check fails
Decision A pull request pushes the home route 25 KB over its JavaScript budget. What are the choices?
What we pick: Try the options in that order. Most over-budget changes can be shrunk or lazy-loaded in an hour. Trading and raising are business decisions and should involve the budget owner, never be done quietly in the same pull request.
Lab: a pull request adds a dependency
Here is the situation. The product page ships 162 KB of compressed JavaScript today, with a median lab LCP of 2.3 seconds and a lab TBT of 140 ms. On mid-range phones, field p75 LCP is 2.4 seconds and p75 INP is 180 ms. Low-end phones are already over budget at 4.6 seconds.
A pull request adds a countdown timer for a sale, and with it a date library. Choose which library the author used, how it loads, and how strict your budgets are. Then read which checks fail and what the field would show a week after release.
Three lessons fall out of the lab. First, with a budget close to today's size, the size check fails on the large library every time. With a loose 250 KB budget it passes, and only the Lighthouse TBT assertion still blocks the change (the LCP assertion passes too once you loosen it to 3.0 seconds). Tight, exact size checks are the better gate.
Second, lazy-loading keeps the initial route under budget, but the code still arrives and still costs main-thread time, so INP can move even when LCP does not. Third, the field impact is largest on low-end phones, which no lab check sees directly. The numbers come from a simple model built on this lesson's rules of thumb, so read them for direction, not precision.
Check your understanding · Exercise 8
A pull request is 12 KB over the home route budget and also edits .size-limit.json to raise the limit by 15 KB. What should happen? Select all that apply.
In real products and tools
Performance budgets are not a niche idea. Build tools ship them, frameworks set defaults, and many large engineering teams have written about how they defend speed. The details vary, but the same pattern repeats: a few numbers per page type, a hard check in CI, a field dashboard, and someone who owns them.
What the tools do by default
| Tool | What it checks | Default behaviour | Unit |
|---|---|---|---|
| webpack | Each asset and entry point | Warns over 250,000 bytes in production mode | Minified, uncompressed |
| Angular CLI | Initial bundle, component styles, named bundles | New projects warn at 500 kB and fail at 1 MB of initial bundle | Uncompressed |
| Next.js | First Load JS per route (up to version 15) | Printed a table after next build, never failed it; removed in Next.js 16 | Compressed |
| size-limit | Any files you list | Fails when a file is over its limit | Brotli by default, gzip optional |
| Lighthouse CI | Any audit or category | Nothing until you write assertions or use a preset | Bytes, ms, score |
The defaults are a useful floor. The Angular CLI's out-of-the-box budget means a new Angular app fails its production build if the initial bundle passes 1 MB, which stops the worst cases even on teams that never think about performance. Keep framework budgets on, then tighten them to your own numbers.
How large teams run it
Public write-ups from large sites describe similar setups. Etsy was an early, often-cited example: its engineers wrote and spoke in the early 2010s about setting performance budgets for pages and checking new features against them. Wikimedia's performance team has described running synthetic tests and field monitoring for Wikipedia and alerting when key metrics move after a deploy. Treat the exact numbers in such posts as dated, but the shape of the process has held up.
- A small number of guarded pages. Teams usually protect the few page types that carry the business: landing, search, product, checkout, and the main logged-in view. Ten budgets that people watch beat a hundred that nobody reads.
- Bots that talk in pull requests. Size diffs and Lighthouse links appear as comments, so the cost is visible at review time, where it is cheap to act.
- Field dashboards per release. Every deploy appears as a marker on p75 charts per segment, and on-call engineers see performance alerts next to error alerts.
- Regular reviews. A weekly or monthly look at the trend lines, with the route owners, catches slow drift that no single alert fires on.
Budgets beyond page load
The same idea applies to anything you can count or time. Long-lived apps such as editors and dashboards often budget memory too: "heap after 30 minutes of use stays under 150 MB", checked by a scripted test that compares heap snapshots, as our lesson on memory leaks shows. Single-page apps budget route changes ("p75 soft navigation under 1 second") with User Timing marks. Design systems budget each component's size so one heavy component cannot slip into every page.
Check your understanding · Exercise 9
A dashboard app is used all day. Which budgets make sense for it beyond initial load? Select all that apply.
Using it in a system design interview
Performance budgets rarely are the interview question. They are what separates a design that is fast on the day it launches from one that stays fast. Bring them up near the end, in the optimisation or non-functional part, after you have described the main design and its performance choices.
What to say, in about a minute
- State the goal in field terms: "p75 LCP at or below 2.5 seconds and INP at or below 200 ms on phones, per page type."
- Turn it into a quantity: "On a mid-range phone that means roughly 120 to 170 KB of compressed JavaScript on the critical path for this page."
- Enforce it before merge: "size-limit per route in CI, and Lighthouse CI asserting LCP, TBT, and CLS on the key pages with a median of 5 runs."
- Watch it after release: "web-vitals RUM tagged with release, route, and device class; alerts on per-segment p75 compared with last week, and canary comparisons on rollout."
- Say who owns it: "each route team owns its budget; raising one needs approval and an expiry date."
How deep to go by level
| Level | What a strong answer includes |
|---|---|
| Junior | Knows the Core Web Vitals thresholds and that you can fail a build on bundle size. Mentions measuring real users, not only Lighthouse. |
| Mid | Names quantity and milestone budgets, sets them from a baseline, uses size-limit and Lighthouse CI with several runs, and reads field data at p75. |
| Senior | Derives a JS budget from the LCP goal on a target device, separates lab and field budgets, segments by device and network, designs noise-aware alerts and canary comparisons, and describes ownership and exceptions. |
If you have a whiteboard, draw the loop from the figure above: pull request, size and lab checks, merge, gradual rollout, RUM, dashboards and alerts, and back to the budgets. It takes thirty seconds and shows you think about performance as a system that runs every day, not a one-off tuning pass.
Think first
You are designing a news feed for a market where most users have low-end Android phones. How do you set and enforce budgets?
Set the field goal on the segment that matters: p75 LCP at or below 2.5 seconds and INP at or below 200 ms on low-end phones, not overall.
Work backwards on a slow profile: with a 150 ms round trip and a slow CPU, a client-rendered feed leaves room for less than 100 KB of JavaScript before LCP (about 80 KB in the calculator above), so server-render the first items and hydrate lazily.
Enforce size-limit per route (feed shell, post detail, composer as a lazy chunk) and Lighthouse CI on the feed URL with a median of 5 runs.
Collect RUM with web-vitals tagged by release and device class, alert on low-end p75 against last week, and roll out releases to 5 percent first and compare.
Summary
- Sites get slow through many small changes, not one big one. A budget turns slow drift into one failing check on one pull request.
- Use milestone budgets (p75 LCP, INP, CLS) as the goal, quantity budgets (compressed KB per route) as the CI gate, and scores only as a summary.
- Set the first budgets at today's values and ratchet them down; use competitors and worked-backwards arithmetic for the target.
- Enforce in CI with a size check (exact, fast) and Lighthouse CI assertions (median of several runs). Check the unit: compressed and minified sizes differ by about 3 times.
- Keep separate lab and field budgets and calibrate one against the other; RUM with web-vitals, tagged with release, route, and device, is the source of truth.
- Judge and alert per segment (device, network, route), never only overall, and design alerts with baselines, durations, sample minimums, and hysteresis.
- Budgets last only with owners, protected budget files, and exceptions that expire.
In the interview
Questions interviewers ask about this. Answer each out loud first, then compare.
Question 1
What is a performance budget, and what kinds are there?
A limit the team agrees on before the work, checked by a machine on every change and watched in production.
Quantity budgets limit what you send (KB of JavaScript, CSS, images, request and third-party counts). They are exact and point at the file that grew.
Milestone budgets limit when things happen (LCP, INP, CLS, TBT in the lab). They are what users feel but are noisy.
Rule-based budgets limit a score such as Lighthouse performance. Good as a summary, poor as the only gate because a score hides which metric moved.
Question 2
How would you choose the JavaScript budget for a product page?
Start from the baseline: measure today's compressed JS per route and set the CI limit slightly above it, as a ratchet.
Set a target by working backwards from p75 LCP of 2.5 seconds on a mid-range phone: about 0.8 seconds to TTFB, time for CSS and the hero image, and roughly 8 to 9 ms per compressed KB of JS for download and parse. That gives on the order of 120 to 170 KB on the critical path.
Sanity-check against competitors' CrUX data and against outside guidance such as Alex Russell's yearly analysis, then lower the ratchet toward the target each quarter.
Question 3
Lighthouse CI passes but field p75 LCP is failing. Why, and what do you do?
The lab uses one device and network profile, often a warm or stubbed backend, and may block third-party tags. Real users have slower phones, cold caches, personalised pages, and every tag.
Segment the RUM data by device, network, navigation type, and route to find who is slow. Use the web-vitals attribution build to see which part of LCP is slow: TTFB, resource load delay, load time, or render delay.
Fix the cause, then recalibrate the lab budget so it reflects the field gap, and add a CI check that would have caught it.
Question 4
How do you alert on field performance regressions without drowning in noise?
Alert on per-segment p75, not the overall number, so traffic mix shifts do not fire alerts.
Compare against a matched baseline, such as the same hour last week, with a relative threshold like +15 percent.
Require a duration (2 hours or 3 checks in a row), a minimum sample count, and hysteresis so the alert does not flap.
For deploys, prefer a canary comparison: 5 percent of traffic on the new release against the rest at the same time.
Question 5
A pull request breaks the JavaScript budget by 25 KB. Walk me through what happens.
The size check fails and the bot comments with the before and after sizes, so the author sees the cost.
The author first tries to shrink the change (a smaller dependency, a built-in API, better imports) or lazy-load it off the critical path.
If the feature is worth the cost, the budget owner approves a raise with a reason and an expiry date; the budget file is protected by code owners so this cannot happen silently.
After release, the team checks field p75 for the affected segments to confirm the impact matched the estimate.
Practice it
These design problems lean on performance budgets and monitoring.
References
- Performance budgets 101 web.dev's introduction to kinds of budgets and how to pick them.
- Lighthouse CI configuration Collect, assert, and upload options, including assertion levels and aggregation methods.
- size-limit The size check used in this lesson, with its GitHub Action for pull request comments.
- web-vitals library How to measure LCP, INP, and CLS in the field, and the attribution build.
- webpack: performance Performance hints, their defaults, and asset filters.
- Chrome UX Report (CrUX) Public field data for comparing with competitors.
- The Performance Inequality Gap, 2024 Alex Russell's device and network analysis behind the 365 KB JavaScript guidance.
Read next
- Core Web VitalsThree numbers, measured on real users, that say whether a page loads fast (LCP), reacts fast (INP), and stays still (CLS): what each one measures, what makes it bad, and how to fix it.
- Real-user monitoring (RUM)Measuring speed on your real users' devices and networks: collecting Web Vitals and Performance API timings, sending them safely, and reading them as percentiles by device, network, country, and page to find what an average hides.
- Code splitting and bundlingBreaking one big JavaScript bundle into smaller pieces that load only when needed, because every kilobyte of JavaScript costs download, parse, and run time on the user's device.
- Back-of-the-envelope numbers for frontendQuick, rough math with round numbers (bytes over the network, CPU time on a cheap phone, memory in a long-lived tab, and requests per second at the server) that turns "this might be slow" into a reason to pick one design over another.
- Memory in long-lived appsWhy single-page apps that stay open for hours fill up memory, how the garbage collector decides what to keep, the classic leaks, and how to find them with heap snapshots and keep them out with budgets.