cf.completefrontend
My progress
FUNDAMENTALS · MODULE 8

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.

A bar chart of the product page's compressed JavaScript over one year, to scale. January launch: 180 KB with LCP 1.9 seconds. After a date library: 204 KB. After an analytics tag: 242 KB. After a carousel: 273 KB. After a component library dropdown: 333 KB. In December, after about 400 pull requests: 520 KB with LCP 3.6 seconds. A dashed line at 200 KB shows that a budget there would have failed on the date library change.January launch180 KB, LCP 1.9 s+ date library for a countdown204 KB (+24)+ analytics tag242 KB (+38)+ carousel273 KB (+31)+ component library dropdown333 KB (+60)December, after about 400 PRs520 KB, LCP 3.6 sa 200 KB budget
No single change is large, and each one looks reasonable in review. A machine-checked 200 KB budget would have stopped the drift at the second bar, on one specific pull request.

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.
Three layers of budget, top to bottom, joined by arrows. Quantities, the causes: kilobytes of JavaScript, kilobytes of images, and request counts; exact and cheap, used as the CI gate. They cause milestones, the effects: LCP, INP, and CLS; what users feel, measured in the lab and the field. Milestones are summarised by a rule-based budget: the Lighthouse score; one number, used as a warning only.Quantities: the causesJS KBimage KBrequestsexact, cheapthe CI gateMilestones: the effectsLCPINPCLSwhat users feellab and fieldRule-based: the summaryLighthouse scoreone numberwarn only
Each layer is computed from the one above it. Defend the bytes so you hit the timings; report the score, but never let it be the only check.

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.

The three kinds of budget. Quantities are precise and actionable; milestones are what matters; scores are a summary.
QuestionQuantityMilestoneRule-based
ExampleJS at most 170 KB gzipp75 LCP at most 2.5 sLighthouse score at least 90
Close to what users feel?IndirectYesPartly
Noise between runsNone10 to 20 percent in the labA few points
Where you check itCI, every commitLab in CI and field with RUMLab in CI
Points to a cause?Yes, the file that grewOnly with attributionNo

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.

  1. 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.
  2. 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.
  3. What is left for JavaScript. About 1.1 seconds, minus some slack for rendering: call it 1.0 second.
  4. 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).
  5. 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.

Device:
Network:
LCP goal:
Time budget to an LCP goal of 2.5 seconds on a mid-range phone over Slow 4G (Lighthouse): TTFB 800 ms, critical CSS 100 ms, hero image 500 ms, render slack 100 ms, leaving 1000 ms for JavaScript. Each compressed KB of JavaScript costs about 8.5 ms to download and run, so the budget is about 117 KB of compressed JavaScript before LCP.goal 2.5 s0 s1 s2 s3 s4 sconnection and server (TTFB): 800 mscritical CSS, 20 KB: 100 mshero image, 100 KB: 500 msrender slack: 100 msleft for JavaScript: 1000 msBudget: about 117 KB of compressed JavaScript before LCP
Each compressed KB costs 5.0 ms to download plus 3.5 ms to parse and run (3.5 KB unzipped). Try the low-end phone on slow 4G: the same page needs a far smaller budget.

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?

Baseline ratchet

Measure today's sizes and p75 timings

Set each budget at today's value plus 5 percent headroom

Lower the budget whenever the page gets smaller

Pros

  • Passes on day one, so the team accepts it
  • Stops decay immediately
  • Every improvement is locked in

Cons

  • Says nothing about whether today is good enough
  • A slow site stays slow unless someone also sets a goal

Competitor-relative

Pull p75 field timings for 3 to 5 rivals from CrUX

Aim about 20 percent faster than the fastest on your key pages

Pros

  • Tied to what users actually compare
  • Easy to sell to product and leadership

Cons

  • Only works for timings, not bytes
  • If every rival is slow, the bar is low

Work backwards from a goal

Pick a milestone (LCP 2.5 s) on a target device and network

Subtract server, CSS, and image time

Convert what is left into KB of JavaScript

Pros

  • Grounded in physics, not opinion
  • Gives quantity budgets that predict the timing goal

Cons

  • Rough: overlap and device variety make it approximate
  • May produce a number far below today's, which fails at once

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.

A flowchart of checks on a pull request. The pull request is built in production mode. A size-limit check asks whether the route's JavaScript is under 170 KB. If no, the pull request is blocked and a bot comments with the size change, like plus 31 KB. If yes, Lighthouse CI asks whether the median LCP over 5 runs is under 2.5 seconds. If no, it is blocked. If yes, it can merge. The size check takes seconds and is exact; Lighthouse CI takes minutes and uses the median of 5 runs.PRopenedbuildproductionsize-limitJS under 170 KB?Lighthouse CILCP under 2.5 s?blockedbot: +31 KBmergeyesnonoyessize check: seconds, exactLighthouse CI: minutes, median of 5 runs
The fast, exact check runs first and catches most problems. The slower lab check catches what bytes cannot show, like a render-blocking stylesheet or a layout shift.

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.

json
[
  {
    "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
  }
]
A .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 performance option warns or errors when an asset or entry point is over maxAssetSize or maxEntrypointSize (both 250,000 bytes by default). In production mode it only warns; set hints: "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.json has a budgets array per build configuration, with types such as initial, anyComponentStyle, and bundle, each with maximumWarning and maximumError. 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 build printed 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.

json
{
  "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" }
  }
}
A 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?

Size check (size-limit)

Runs on the build output

Compares compressed bytes per route with a limit

Pros

  • Exact and repeatable, never flaky
  • Runs in seconds
  • Points at the file that grew

Cons

  • Measures a cause, not what users feel
  • Misses slow code that is small, and slow servers

Lighthouse CI assertions

Loads key URLs in throttled headless Chrome

Asserts LCP, TBT, CLS, and resource sizes

Pros

  • Checks real milestones before release
  • Catches render-blocking resources and layout shift

Cons

  • Noisy: needs several runs and some headroom
  • Slower (minutes) and needs a running app
  • No INP: there are no clicks

Score only

Fail if the Lighthouse performance score drops below a value

Pros

  • One number everyone understands

Cons

  • Hides which metric moved
  • Weights change between Lighthouse versions
  • Easy to game

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.

Lab and field answer different questions. Lab budgets protect; field budgets judge.
QuestionLab (Lighthouse CI)Field (RUM, CrUX)
When you get itBefore merge, in minutesAfter release, hours to days
Devices and networksOne fixed profileEverything your users have
INPNot measured (TBT as a stand-in)Measured from real taps and keys
NoiseLow with several runsHigh per visit, low at p75 over many visits
Good forBlocking regressions, debuggingKnowing the truth, setting priorities
Budget formMedian of N runs under a limitp75 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.

ts
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();
});
The smallest useful RUM client: every metric carries the release, the route template, and device and network hints, and the batch goes out with 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.

A sequence diagram with four lifelines: the page, the web-vitals library, a collector endpoint, and a data warehouse. The page registers onLCP, onINP, and onCLS. web-vitals reports LCP 2.1 seconds, then INP 180 milliseconds after a tap, then CLS 0.04 when the page is hidden. The page sends the batch to the collector with sendBeacon when it is hidden. The collector adds the country and buckets the device hints into a device class, then stores each sample, already tagged with release and route, in the warehouse, which computes p75 per segment, hourly and per release.pageweb-vitalscollectorwarehouseonLCP/INP/CLSLCP 2.1 sINP 180 ms, tapCLS 0.04, hiddensendBeacon on hide+ country, devicep75 per segment,hourly and per release
Metrics arrive at different times: LCP early, INP and CLS only when the user leaves. That is why the batch goes out on 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.

Group the same visits:
p75 LCP on the product page, everyone: All visits, 100% of visits, 2.3 seconds. The budget is 2.5 seconds.budget 2.5 sAll visits100% of visits2.3 s0 s1 s2 s3 s4 s5 s
One number for everyone: 2.3 s at p75, under budget. Now group the same visits another way.

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) and navigator.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.effectiveType reports 4g, 3g, 2g, or slow-2g from 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 navigationType field on each web-vitals metric 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.

Example product page. The overall row passes both budgets while 35 percent of visits, every mid-range and low-end phone, fail both.
SegmentShare of visitsp75 LCPBudget 2.5 sp75 INPBudget 200 ms
Desktop35%1.4 sPass80 msPass
Phones, high-end30%1.9 sPass120 msPass
Phones, mid-range22%2.8 sFail240 msFail
Phones, low-end13%4.6 sFail520 msFail
All visits100%2.3 sPass170 msPass

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.

p75 LCP on phones over 14 days, around 2.1 seconds, under the 2.5 second budget. On day 9, right after release v42, it steps up to about 2.85 seconds and stays there. An alert fires after it holds. A fix ships as v43 on day 13 and p75 drops back to 2.1 seconds.budget 2.5 sv42v43 fixalert2 s3 s14 days of p75 LCP on phones
The step lines up with one release marker, which points straight at the cause. A lasting step, not one noisy point, is what should page someone.

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.

  1. A per-segment p75, not the overall number, so mix shifts do not fire alerts.
  2. 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.
  3. 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.
  4. 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.

A state machine for a field performance alert with three states. OK, within baseline. When p75 rises more than 15 percent over the same hour last week, it moves to Pending, which waits and counts samples. If the value drops back, it returns to OK. If it holds for 2 hours with at least 500 samples per hour, it moves to Firing, which pages the owner. Firing returns to OK only when p75 is back under plus 8 percent for an hour; the gap between 15 and 8 percent is hysteresis.OKwithin baselinePendingwait and countFiringpage the ownerover +15%drops backheld 2 hours500+ samplesback under +8% for 1 hourthe gap between 15 and 8 is hysteresis
The pending state absorbs short blips, the sample minimum ignores thin data, and the lower clear threshold stops an alert from flapping on and off around the line.

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.json and lighthouserc.json under 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?

Shrink the change

Swap the dependency for a smaller one or for a built-in API (Intl, fetch, CSS)

Import only the parts you need so tree shaking can drop the rest

Pros

  • Fixes the cause
  • Often the cheapest option once someone looks

Cons

  • Needs time the author may not have planned for

Move it off the critical path

Lazy-load the feature with a dynamic import() on interaction or when it scrolls into view

Move the work to a web worker or to the server

Pros

  • The feature still ships
  • The initial route stays within budget

Cons

  • The feature itself loads later; needs a loading state
  • Lazy code still costs INP when it arrives

Trade within the budget

Remove or lazy-load something else of similar size on the same route

Pros

  • Forces a real priority decision
  • Keeps the total fixed

Cons

  • Needs product input on what matters less

Raise the budget, with sign-off

The owner approves a higher limit with a reason and an expiry date

Track the field impact after release

Pros

  • Honest when the feature is worth the cost

Cons

  • Easy to abuse; every raise makes the next one easier

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.

The PR uses:
Loading:
size-limit budget:
Lighthouse LCP limit:
Results for a pull request that adds full date library, in the initial bundle. CI checks: size-limit: initial JS, limit 170 KB: 234 KB, fail; Lighthouse CI: median LCP, limit 2.5 s: 2.91 s, fail; Lighthouse CI: TBT, limit 200 ms: 248 ms, fail. Field a week later: p75 LCP, mid-range phones (was 2.40 s): 3.01 s, fail; p75 INP, mid-range phones (was 180 ms): 223 ms, fail; p75 LCP, low-end phones (was 4.60 s): 5.46 s, fail.before merge: CI checks (modelled)size-limit: initial JS, limit 170 KB234 KBfailLighthouse CI: median LCP, limit 2.5 s2.91 sfailLighthouse CI: TBT, limit 200 ms248 msfaila week after release: field p75 (modelled)p75 LCP, mid-range phones (was 2.40 s)3.01 sfailp75 INP, mid-range phones (was 180 ms)223 msfailp75 LCP, low-end phones (was 4.60 s)5.46 sfail
3 of 3 CI checks fail, so the pull request is blocked before any user pays for it. Had it shipped, the field would show it too.

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

Check the unit before you compare numbers across tools: the same bundle can be 240 KB to webpack and 80 KB to size-limit.
ToolWhat it checksDefault behaviourUnit
webpackEach asset and entry pointWarns over 250,000 bytes in production modeMinified, uncompressed
Angular CLIInitial bundle, component styles, named bundlesNew projects warn at 500 kB and fail at 1 MB of initial bundleUncompressed
Next.jsFirst Load JS per route (up to version 15)Printed a table after next build, never failed it; removed in Next.js 16Compressed
size-limitAny files you listFails when a file is over its limitBrotli by default, gzip optional
Lighthouse CIAny audit or categoryNothing until you write assertions or use a presetBytes, 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.

Three zones side by side, left to right. Before merge: pull request, size-limit, Lighthouse CI. Release: a 5 percent canary, compare the groups, full rollout. Production: web-vitals RUM, p75 by segment, alerts. Below them sits the budgets file, owned and protected. CI enforces the budgets before merge, and field data from production recalibrates the budgets, closing the loop.Before mergepull requestsize-limitLighthouse CIReleasecanary 5%compare groupsfull rolloutProductionweb-vitals RUMp75 by segmentalertsbudgets fileowned, protectedenforcedrecalibrates
Budgets are the one artefact every stage reads. CI enforces them, the canary checks them on real traffic, and production data tells you when to tighten or recalibrate them.

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

  1. 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."
  2. 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."
  3. 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."
  4. 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."
  5. Say who owns it: "each route team owns its budget; raising one needs approval and an expiry date."

How deep to go by level

LevelWhat a strong answer includes
JuniorKnows the Core Web Vitals thresholds and that you can fail a build on bundle size. Mentions measuring real users, not only Lighthouse.
MidNames quantity and milestone budgets, sets them from a baseline, uses size-limit and Lighthouse CI with several runs, and reads field data at p75.
SeniorDerives 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?

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?

Question 2

How would you choose the JavaScript budget for a product page?

Question 3

Lighthouse CI passes but field p75 LCP is failing. Why, and what do you do?

Question 4

How do you alert on field performance regressions without drowning in noise?

Question 5

A pull request breaks the JavaScript budget by 25 KB. Walk me through what happens.

Practice it

These design problems lean on performance budgets and monitoring.

References

Read next