Skip to content

    Frontend System Design

    Performance Budgets and Profiling

    Learn to set performance budgets for bytes, requests and metric percentiles, enforce them in CI and production, and profile in DevTools to find why one failed.

    By Published 15 min read

    • frontend-system-design
    • performance-budget
    • profiling
    • devtools
    • ci
    Share

    Standard lessonintermediateTypeScript labFrontend System Design

    Before this: Loading Performance and Core Web Vitals

    What you'll learn: By the end of this lesson you will be able to describe the three kinds of performance budget (quantity limits, timing or user-centric metrics, rule-based scores), choose limits from your own data instead of copying numbers, enforce them at the points where a change is made (build, CI, production), read a metric budget as a percentile over enough samples, and use the Chrome DevTools Performance panel to go from "the budget failed" to "this function on the main thread is the cause". The lab is a small budget checker that turns a stats file into a pass or fail report.

    At an airport you are weighed at check-in, not at the gate. The limit is agreed in advance, it is checked while you can still do something about it (take the book out of the bag), and when you are over you decide what to leave behind. Nobody argues about the 23rd kilogram at the aircraft door. A performance budget is that check-in desk for a web page.

    Web.dev defines it as "a set of limits imposed on metrics that affect site performance", used as "a point of reference for making decisions about design, technology, and adding features". The Core Web Vitals lesson introduced budgets as a design artefact. This lesson is the working detail: what kinds there are, how to enforce them, and what to do after one fails.

    The running example is the store's product page again, with a build pipeline that checks every pull request. The numbers in this lesson are illustrative, taken from hand-written fixture data in the lab, except the Core Web Vitals "good" values, which are from web.dev.

    The core idea

    1. A budget is a limit agreed before the change, checked where the change is made. A limit nobody checks until production is a wish.
    2. A budget says that something regressed. A profile says why. The two are a pair: the check finds the pull request, the profile finds the function.
    3. A number is only as good as its measurement. Bytes from a build are exact. A metric at the 75th percentile of a handful of runs is noisy, and a good checker says "no data" instead of guessing.

    Three kinds of budget

    Web.dev's guide names three, and advises combining the first two.

    Kind Example Good at Weak at
    Quantity Script on the product route at most 200 KiB; third-party requests at most 4 Early warning, easy to explain to designers and developers, checked in CI Does not capture the experience: load order matters, not just totals
    Timing or user-centric metric LCP at most 2.5 s at p75 Measures what users feel Needs measurement, and noise makes it slow to attribute
    Rule-based A Lighthouse score above a threshold A single number, with hints for simple fixes One number hides which input moved

    Web.dev's thresholds for the Core Web Vitals, at the 75th percentile of page loads segmented across mobile and desktop: LCP within 2.5 seconds, INP of 200 milliseconds or less, CLS of 0.1 or less. Those are a sensible metric budget to start from. Everything else (bytes, counts) you choose.

    Choosing the numbers. Start from your own data: what the page weighs and scores now, and what the slowest important device and network can take. Web.dev suggests testing, studying competitors, and setting budgets per page type (its examples: a product page limited in JavaScript, a search page limited in images). Its fallback defaults (a Time to Interactive target and about 170 KB for critical-path resources) come from that article's baseline device and network; do not copy them, because your pages and devices differ.

    What happens when a change breaks one. The guide's three options: optimise it, remove something else, or do not add it. A budget turns a vague debate ("it is only one library") into a trade ("what do we drop for it?").

    Where to enforce

    Figure 1. Budgets at four points

    Budgets are checked on the pull request, in a lab run and in productionPull request to CI check (build output); CI check to Lab run (pass); Lab run to Field data (release); CI check to DevTools profile (fail: why?)BEFORE MERGEAFTER RELEASEPull request: code changePull requestcode changeCI check: bytes, requestsCI checkbytes, requestsLab run: controlled device, networkLab runcontrolleddevice, networkField data: p75 of real visitsField datap75 of realvisitsDevTools profile: find the causeDevToolsprofilefind the causebuild outputpassreleasefail: why?

    How to read this: the cheapest, fastest checks come first. A failing check sends the author to a profile, not to a debate. Field data comes last and is the truth about users, but it arrives after the change has shipped.

    • Build time. The build output has exact numbers. Web.dev's build-tool guide describes webpack performance hints that warn when assets or entry points exceed a limit (its default is 250 KB, measured on uncompressed asset sizes), size-limit tools that check files, usually gzipped, and CI jobs that block a pull request when a check fails.
    • A lab run. Lighthouse accepts a budget file. Web.dev's example has path, timings, resourceSizes and resourceCounts entries (for instance "resourceType": "script" with a "budget" in kilobytes), and the report gets a Budgets section under Performance. The file can be named anything and is passed with --budget-path. A lab run is controlled but is not your users.
    • Production. Field metrics at p75, alerting when they cross a limit. Web.dev adds that you should keep measuring after launch and collect data from real users.
    Point Speed of feedback Truthfulness Typical gap
    Build check Seconds Exact for bytes, silent about experience Misses what is requested at runtime
    Lab run Minutes Controlled, repeatable A fast CI machine, no real third parties
    Field Days Real users Slow to attribute, needs volume

    Metric budgets are percentiles over samples

    A metric budget compares one number to a limit, and that number is a percentile of many page views. For the Core Web Vitals web.dev uses the 75th. Two consequences.

    • Use the stated percentile, not the worst case or the mean. The worst single visit is almost always bad; the median hides the slow quarter of users.
    • Enough samples or no verdict. A p75 of six runs is not a p75. The lab's checker uses nearest-rank (the value at rank ceil(p/100 * n) of the sorted samples) and returns no data, which fails the run, when a metric has fewer samples than a stated minimum. That is a design choice, and the minimum is yours to set.

    Likewise a size budget on a pattern that matches nothing, say after a bundler renamed its output, should not pass silently.

    Build it: a budget checker over a stats file

    Goal. Check a build's measurements (bundle sizes, request counts and sizes per page, metric percentiles) against a budget file and read the pass, warn, fail and no-data results the way a CI job would.

    Setup. The lab is examples/frontend/labs/perf-budget-checker. It needs Node.js (captured on Node 26.12.1).

    cd examples/frontend
    pnpm install
    cd labs/perf-budget-checker
    pnpm test            # 36 tests
    node src/demo.ts
    node src/cli.ts data/budget.json data/stats-pr.json --baseline data/stats-main.json; echo $?

    Steps.

    1. Open data/budget.json. The first entry that matches a page's path wins, so specific paths come first (section 3 shows /product/a/b matching nothing, because * stays inside one path segment).
    2. In section 4 (the main branch), find the two WARN lines: within 90% of a limit, still passing. The run passes.
    3. In section 5 (the pull request), read the five FAIL lines. The page-level script total (357 KiB) is over by more than the entry bundle alone, because the page also requests a charting chunk that no bundle pattern names.
    4. Find the NODATA line: only 6 INP samples, need 10. The run fails on it, because a budget that cannot be measured is not passing.
    5. Read section 7: the command line exits 0, 1 or 2.

    Expected output, captured from a real run (Node 26.12.1, 2026-11-08):

    === 1. The budget file (data/budget.json)
      page entries: /product/*, /
      bundle limits: assets/index-*.js <= 120 KiB; assets/vendor-*.js <= 90 KiB; assets/*.css <= 30 KiB
      warn at 90% of a limit; a percentile needs at least 10 samples
    
    === 2. Nearest-rank percentile (the product page's 12 LCP samples, ms)
      sorted: 1650, 1720, 1810, 1900, 1950, 2010, 2100, 2150, 2200, 2300, 2380, 2900
      p50 = 2010 (rank 6 of 12)
      p75 = 2200 (rank 9 of 12)
      p90 = 2380 (rank 11 of 12)
    
    === 3. Which budget entry applies to a path (first match wins)
      /                    -> /
      /product/kettle-2l   -> /product/*
      /product/a/b         -> none (not checked)
      /about               -> none (not checked)
    
    === 4. The main branch build
    build main@a1b2c3d (illustrative)
    PASS   / size script: 124 KiB <= 150 KiB
    PASS   / size total: 340 KiB <= 900 KiB
    PASS   / count third-party: 1 requests <= 3 requests
    PASS   / metric lcp p75: 1850 ms <= 2500 ms  (n=10)
    WARN   /product/kettle-2l size script: 195 KiB <= 200 KiB
    PASS   /product/kettle-2l size image: 381 KiB <= 600 KiB
    PASS   /product/kettle-2l size total: 674 KiB <= 1200 KiB
    PASS   /product/kettle-2l count third-party: 3 requests <= 4 requests
    PASS   /product/kettle-2l count total: 15 requests <= 40 requests
    PASS   /product/kettle-2l metric lcp p75: 2200 ms <= 2500 ms  (n=12)
    PASS   /product/kettle-2l metric inp p75: 168 ms <= 200 ms  (n=12)
    PASS   /product/kettle-2l metric cls p75: 0.08 <= 0.10  (n=12)
    WARN   bundle assets/index-*.js: 112 KiB <= 120 KiB  (1 file)
    PASS   bundle assets/vendor-*.js: 62 KiB <= 90 KiB  (1 file)
    PASS   bundle assets/*.css: 24 KiB <= 30 KiB  (1 file)
    NOTE   /about: no budget entry matches this page, so it was not checked
    result: PASS (13 pass, 2 warn, 0 fail, 0 no data)
    
    === 5. A pull request build
    build pull-request-1234 (illustrative)
    FAIL   / size script: 180 KiB > 150 KiB
    PASS   / size total: 396 KiB <= 900 KiB
    PASS   / count third-party: 1 requests <= 3 requests
    PASS   / metric lcp p75: 1850 ms <= 2500 ms  (n=10)
    FAIL   /product/kettle-2l size script: 357 KiB > 200 KiB
    PASS   /product/kettle-2l size image: 381 KiB <= 600 KiB
    PASS   /product/kettle-2l size total: 836 KiB <= 1200 KiB
    FAIL   /product/kettle-2l count third-party: 5 requests > 4 requests
    PASS   /product/kettle-2l count total: 18 requests <= 40 requests
    FAIL   /product/kettle-2l metric lcp p75: 2700 ms > 2500 ms  (n=12)
    NODATA /product/kettle-2l metric inp p75: no value (limit 200 ms)  (6 samples, need at least 10)
    PASS   /product/kettle-2l metric cls p75: 0.05 <= 0.10  (n=12)
    FAIL   bundle assets/index-*.js: 168 KiB > 120 KiB  (1 file)
    PASS   bundle assets/vendor-*.js: 62 KiB <= 90 KiB  (1 file)
    PASS   bundle assets/*.css: 24 KiB <= 30 KiB  (1 file)
    NOTE   /about: no budget entry matches this page, so it was not checked
    result: FAIL (9 pass, 0 warn, 5 fail, 1 no data)
    
    === 6. Bundle sizes against the main branch
    assets/index-*.js: 112 KiB -> 168 KiB, +56.0 KiB (+50.0%)
    assets/vendor-*.js: 62 KiB -> 62 KiB, +0.0 KiB (+0.0%)
    assets/*.css: 24 KiB -> 24 KiB, +0.0 KiB (+0.0%)
    
    === 7. The command line, as CI would call it
      main          exit code 0
      pull request  exit code 1
      missing file  exit code 2
    
    === 8. A bad budget file is rejected with a pointer to the problem
      budget.pages[0].resourceSizes[0].resourceType must be one of document, script, stylesheet, image, font, media, other, third-party, total
      budget.pages[0].metrics[0].percentile must be above 0 and at most 100

    This is a model, not Lighthouse or a CI product. The stats are hand-written and illustrative (the hosts are example domains), the budget format is the lab's own (inspired by web.dev's guides, not compatible with Lighthouse's budget.json), and the percentile uses a nearest-rank definition that a vendor's tool may compute differently. The lab did not check how Lighthouse matches a budget path to a URL.

    Break it. In src/budget.ts, change the no-data branch of the metric check to record "pass". Three tests fail, and the pull request would go green with an INP number nobody measured. Revert.

    Stretch. Add a rule that fails when any page in the stats file has no budget entry, and think about why a "NOTE" is not enough.

    Profiling: from a failed budget to a cause

    A budget tells you which change broke something and by how much. To fix it you need to see what the main thread did.

    Figure 2. The profiling loop in DevTools

    flowchart TD accTitle: Finding a cause with the DevTools Performance panel accDescr: Set CPU and network throttling, record a page load or an interaction, look for long tasks marked with a red triangle, open the call tree and bottom-up tables to find where the time is spent, make a change, and record again. A["Throttle CPU (and network)
    in Capture settings"] --> B["Record: reload,
    or interact and stop"] B --> C["Main track:
    find long tasks (red marks)"] C --> D["Select the task:
    Call tree for the root cause"] D --> E["Bottom-up: functions with
    the most self time"] E --> F["Change one thing"] F --> G["Record again, compare"]

    How to read this: profile first with throttling, because a developer laptop is faster than the devices that decide your field numbers. Change one thing at a time and record again.

    What the Chrome DevTools pages say to use:

    • Throttle. "Whenever you profile a page, use CPU Throttling to simulate how your page performs on mobile devices." Throttling is relative to your computer's capabilities, and desktop throttling cannot fully simulate a mobile CPU, so treat the result as a hint about direction.
    • Record. Record and reload for page load, or record while you interact and then stop. Screenshots capture frames.
    • Live metrics and Insights. The panel opens with the page's LCP, CLS and INP for the current session, which helps find problems that only appear in certain circumstances, and an Insights sidebar lists issues similar to Lighthouse's, highlighting the related events.
    • The Main track. Its x-axis is time and its y-axis is the call stack: events on top cause the events below. A wider bar means the event took longer. Long tasks are marked with a red triangle, with the part over 50 ms shaded red.
    • Tables. Call tree shows which root activities cause the most work. Bottom-up shows which activities consume the most time directly. Event log shows order. Self time is time spent directly in an activity; total time includes its children. A function with large total time but small self time is a caller; the one with large self time is where the work is.

    Back to the example: the pull request added a charting library. The profile shows one long task at startup whose bottom-up view puts most self time in the library's initialisation. That is a cause, and it points at a fix: load the chart only when the reviews tab opens. See code splitting.

    Figure 3. A pull request check and its follow-up

    sequenceDiagram accTitle: A pull request budget check accDescr: The developer opens a pull request, CI builds and runs the checker against the budget and a baseline, the check fails, the developer profiles and fixes, and the second run passes. participant D as Developer participant CI as CI participant C as Budget checker participant P as DevTools D->>CI: open pull request CI->>C: build output and baseline C-->>CI: exit code 1 with failing lines CI-->>D: check failed (index bundle +56 KiB) D->>P: record with CPU throttling P-->>D: long task, self time in the chart library D->>CI: push: chart loads on demand CI->>C: build output and baseline C-->>CI: exit code 0

    How to read this: the checker states what regressed and where. The profile supplies the reason. Neither replaces the other.

    Trade-offs

    Choice One side The other side
    Hard fail or warning A fail stops regressions; teams learn the budget A noisy fail teaches people to ignore or raise it
    Global or per-route limits Simple Per-route limits fit page types (web.dev's advice) but need maintenance
    Tight or loose limits A small bundle forever Headroom avoids a fight every sprint; a warning band (90% in the lab) gives notice
    Lab or field metrics Lab is fast and repeatable Field is the truth and arrives late
    Bytes or metrics Bytes are exact and cheap Metrics reflect experience; use both

    Illustration 1: the minimal case. One limit: the entry bundle at most 120 KiB. A pull request grows it from 112 to 168 KiB. The check fails with "168 KiB > 120 KiB (+50%)" and the author sees it in minutes.

    Illustration 2: the realistic case. The product page has script, image and total byte limits, third-party and total request-count limits, and LCP, INP and CLS at p75, as in data/budget.json. The home page has its own, tighter, script limit. The warning band shows the product page at 195 of 200 KiB: still passing, and a signal that the next library will not fit.

    Illustration 3: the failure case. The entry-bundle limit passes on a pull request that adds a 48 KiB chart chunk loaded by the product route. The bundle patterns do not name the chunk, so they stay green; the page-level script total, which counts what the page actually requests, is where it shows up (357 KiB against 200). Bundle budgets see files; page budgets see requests. Keep both.

    In practice: a regression the budget did not see

    NOTE

    This is a realistic scenario, not a case history. It is a composite built from common production patterns. It describes no specific company, and every number is an illustrative assumption.

    The situation

    The store's front-end team set per-route budgets and a CI check a quarter ago. Since then, LCP at p75 on mobile drifted from 2.2 s to 2.8 s (illustrative), and nobody noticed because CI stayed green. Five engineers ship to the product page weekly.

    Starting architecture

    Figure 4. The pipeline as built

    CI checks bytes against a budget file keyed by page path; field data feeds a weekly reviewBuild output to Budget checker (sizes, requests); Budget file to Budget checker (limits); Budget checker to CI result (exit code)CI ON THE PULL REQUESTBuild output: files and sizesBuild outputfiles and sizesBudget checker: per-route limitsBudget checkerper-route limitsBudget file: /product/*, /Budget file/product/*, /CI result: pass or failCI resultpass or failField metrics: p75, weekly reviewField metricsp75, weeklyreviewsizes, requestslimitsexit code

    How to read this: the checker only judges pages the budget file names. A page that moves to another path leaves the file's patterns behind, and field metrics are reviewed by people, not by the pipeline.

    Options considered

    Option What it fixes What it costs Risk
    Tighten every limit Smaller pages Constant red builds Teams raise limits to unblock
    Fail when a page has no budget entry, and when a pattern matches nothing Closes the silent gap A one-time cleanup Failing builds on harmless new pages
    Alert on field p75 as well Catches what CI cannot An on-call signal Noise from low traffic

    The decision

    Field Decision record
    Context A route was renamed; the budget entry for the old path matched nothing; CI skipped the page.
    Decision Fail the check for any page without a budget entry, and for any bundle pattern with no matching file; add a field-metric alert at p75 per route with a minimum sample size.
    Consequences, good No page can be silently unbudgeted; field drift is seen within days.
    Consequences, bad New pages need a budget entry on day one.
    Revisit when Alerts fire without a user-visible change.

    Rollout and verification

    1. Replay the last quarter's builds through the stricter checker to see which would have failed, and when.
    2. Add the route list (from the router or sitemap) as a test input, so a route without a budget entry fails.
    3. Alert on field p75 per route only when the sample count is above a floor.
    4. Profile the regression in a throttled run to find the cause before choosing a fix.

    The 2 a.m. failure

    An on-call alert fires: product-page LCP at p75 crossed its limit overnight, but CI is green. First three checks: (1) compare the route in the alert with the paths in the budget file; (2) look for a recent deploy that changed what the page requests (a new third-party script, a tag-manager rule) rather than what the bundle contains; (3) record a throttled profile and look at the first long task. The tag manager had started loading a script after the last release, outside the build output, so no build-time check could see it. Mitigation: disable the tag. Follow-up: a lab run in CI that counts third-party requests on the live-like page, and a request-count budget for them.

    Architect's notes

    • Budget what you can act on. Bytes and request counts in CI; metric percentiles in production; one owner per budget.
    • Prefer a warning band to a cliff. A band tells teams the next change will not fit, before the fail.
    • Make "no data" a failure. An unmeasured budget is a gap.
    • Keep budgets and routes in step. Test that every route has an entry and every pattern matches a file.
    • Profile before you optimise. A budget names the pull request; only a trace names the function.
    • Do not copy others' numbers. Set limits from your own field data and revisit them quarterly.

    Common mistakes

    • Setting a budget and never checking it where the change is made.
    • Using the mean or the worst case instead of the stated percentile.
    • Trusting a percentile built from a handful of samples.
    • Budgeting only the entry bundle and missing chunks and third parties the page requests.
    • Letting a renamed route or pattern leave a page unbudgeted.
    • Profiling on a fast, unthrottled machine and concluding "it is fine".
    • Raising the limit every time it fails.

    Key takeaways

    • A performance budget is a limit agreed in advance and checked where the change is made.
    • Combine quantity budgets (bytes, counts) with metric budgets at a stated percentile.
    • Set budgets per page type, from your own data; the Core Web Vitals "good" values (2.5 s, 200 ms, 0.1 at p75) are a starting metric budget.
    • Enforce in build, in a lab run and in the field; each has a different speed and truthfulness.
    • A budget with too few samples, or no matching file or page, is "no data" and should fail.
    • Use DevTools with CPU throttling: long tasks, the call tree, and bottom-up self time lead from a failed budget to a cause.
    • Bundle budgets see files; page budgets see requests; you need both.

    Practice exercises

    Exercise 1 (45 min): Run the lab's CLI on both stats files. Which lines change between them, and which budget caught the 48 KiB chart chunk?

    Exercise 2 (2 hours): Write a budget file for a page you maintain from its current numbers (size by type, request counts, three metrics). Where did you set the warning band, and why?

    Exercise 3 (2 hours): In DevTools, record a throttled load of a page you own. Find the longest task, open its call tree and bottom-up views, and name the function with the most self time.

    Exercise 4 (half day): Extend the lab so a page in the stats file with no budget entry fails the run, and add a test with a renamed route. What would you do for pages that are intentionally unbudgeted?

    In this course:

    Official documentation

    Ask about this article

    Get answers grounded in this post. AI-generated — based on this article, and may be imperfect.

    Scaled AI Weekly

    Enjoyed this? Get more like it every Monday.

    Real architecture decisions, LLMOps patterns that survive production, and engineering leadership advice — from 12+ years of building at enterprise scale. Free. No spam. Unsubscribe anytime.

    Get new articles by email

    Free. We email a confirmation link first, and you can unsubscribe from any email.

    Join engineers building production AI systems

    Comments