---
title: 'Performance Budget'
source: 'https://academia.sh/en/courses/frontend-quality/performance-budget'
course: 'Frontend Quality'
language: en
updated: '2026-08-17T18:11:06+00:00'
license: 'CC BY-SA 4.0'
---

# Performance Budget

Tying performance decisions to a written limit; budget types, where the limit gets derived from, a program that checks build output against the budget, and catching regressions by comparing two builds.

The previous five lessons built a series of improvements: the metrics got defined, the
critical path got shortened, the assets got sized, the network layer got tuned, the
runtime got drained. All of them share the same gap — they were done once.

When a new feature gets added, the bundle grows; when a library enters the shared
chunk, the repeat-visit gain erodes. This erosion needs to get noticed at the next
release. This lesson builds that mechanism.

## A Budget Is Not a Target

The two get confused, and when they do, a budget loses its function.

A **target** is a value someone wants to reach. It has direction and no enforcement;
nothing happens when it gets exceeded.

A **budget** is a limit that is not allowed to get exceeded. Something happens when it
does: the build counts as failed, the merge stops, the release does not ship. A number
with no enforcement is not a budget.

The practical consequence of this distinction is that a budget gets drawn where it is
reachable. A limit set far below the current value shows red from day one and quickly
starts getting ignored. The right approach is to set the limit slightly above today's
value and lower it over time.

## Three Budget Types

Budgets get separated by what they measure, and all three are needed, because each one
catches what the other misses.

A **metric budget** sets a limit on user-centric metrics: largest contentful paint stays
under this value, cumulative layout shift does not exceed this score. Because it
directly measures the experience, it is the most meaningful budget; it is also the
noisiest, because it is sensitive to measurement conditions.

A **quantity budget** sets a limit on countable magnitudes: chunk sizes, request count,
image weight, font file count. It is deterministic — the same build always produces the
same number — and so it is the budget best suited to enforcement. Its link to the
experience is indirect: a small bundle does not always mean a fast page.

A **rule budget** contains not a number but a condition: no render-blocking external
stylesheet on the first screen, no synchronous script in the document, no lazy-loading of
the image that is a candidate for largest contentful paint. It protects decisions that
cannot get reduced to a number.

A solid setup combines all three: quantity and rule budgets get checked on every build,
the metric budget at regular intervals.

## Where the Limit Comes From

For a number to be a budget, it needs a justification. There are three ways to justify
one.

**Deriving from a metric's standard threshold.** The classification limits noted in the
first lesson — 2.5 seconds for largest contentful paint, 0.1 for cumulative layout shift,
200 milliseconds for interaction to next paint — can get written directly as a metric
budget. The check applies to the 75th percentile.

**Working backward from a metric to a quantity.** A target metric value gets chosen, a
target network and device profile gets assumed, and the budget in between gets converted
to bytes. The round-trip model from the Critical Rendering Path lesson is the skeleton of
this computation: once how many rounds can get spent is decided, how many bytes can get
sent follows.

**Leaving headroom above today's value.** The most practical and least ambitious way. A
small margin gets added on top of the measured value, and the limit gets set there. This
budget delivers no improvement; it **stops things from getting worse.** For most teams,
this is the right starting point.

Whichever way gets chosen, the budget must be written down and live in the repository. A
limit no one can see is a limit that does not exist.

## The Checker

The following program compares a build manifest against the budget, then compares two
builds and reports regressions.

```js
// budget.mjs — compares chunk sizes against a budget and finds regressions between two builds

const KB = 1024;

// Budget: the aggregation rule per group, the upper limit (bytes), and the growth tolerance (ratio).
// "sum" measures the whole group; "largest" measures the single heaviest piece in the group.
const BUDGET = {
  entry:  { aggregation: "sum",     limit: 170 * KB, tolerance: 0.03 },
  shared: { aggregation: "sum",     limit: 120 * KB, tolerance: 0.03 },
  route:  { aggregation: "largest", limit:  45 * KB, tolerance: 0.05 },
  style:  { aggregation: "sum",     limit:  40 * KB, tolerance: 0.05 },
};
const TOTAL_LIMIT = 360 * KB;

// Build manifest: each piece's name, group, and compressed size.
const previousBuild = [
  { name: "entry.4a1c.js",            group: "entry",  bytes: 148_112 },
  { name: "shared.9d02.js",           group: "shared", bytes: 101_760 },
  { name: "route-stations.1f77.js",   group: "route",  bytes:  38_240 },
  { name: "route-measurements.55be.js", group: "route", bytes:  21_904 },
  { name: "station.6b3e.css",         group: "style",  bytes:  31_648 },
];

const newBuild = [
  { name: "entry.7e90.js",            group: "entry",  bytes: 151_296 },
  { name: "shared.c214.js",           group: "shared", bytes: 124_368 },
  { name: "route-stations.02ab.js",   group: "route",  bytes:  39_120 },
  { name: "route-measurements.55be.js", group: "route", bytes:  21_904 },
  { name: "route-report.8c4d.js",     group: "route",  bytes:  17_856 },
  { name: "station.a9f1.css",         group: "style",  bytes:  32_960 },
];

const kb = (bytes) => `${(bytes / KB).toFixed(1)} KB`;

function sumByGroup(build) {
  const measure = new Map();
  for (const [group, { aggregation }] of Object.entries(BUDGET)) {
    const pieces = build.filter((p) => p.group === group).map((p) => p.bytes);
    measure.set(group, aggregation === "largest"
      ? Math.max(0, ...pieces)
      : pieces.reduce((a, b) => a + b, 0));
  }
  return measure;
}

const grandTotal = (build) => build.reduce((t, p) => t + p.bytes, 0);

// --- 1. Budget check ---
function check(build, title) {
  const totals = sumByGroup(build);
  let passed = true;
  console.log(`--- ${title} ---`);
  console.log("group".padEnd(8) + "rule".padEnd(9) + "size".padStart(11) +
    "limit".padStart(11) + "usage".padStart(10) + "  result");
  for (const [group, { limit, aggregation }] of Object.entries(BUDGET)) {
    const size = totals.get(group);
    const usage = size / limit;
    const result = size <= limit ? "pass" : "FAIL";
    if (size > limit) passed = false;
    console.log(group.padEnd(8) + aggregation.padEnd(9) + kb(size).padStart(11) +
      kb(limit).padStart(11) + `${(usage * 100).toFixed(1)}%`.padStart(10) + "  " + result);
  }
  const overallTotal = grandTotal(build);
  const totalResult = overallTotal <= TOTAL_LIMIT ? "pass" : "FAIL";
  if (overallTotal > TOTAL_LIMIT) passed = false;
  console.log("-".repeat(55));
  console.log("TOTAL".padEnd(8) + "sum".padEnd(9) + kb(overallTotal).padStart(11) +
    kb(TOTAL_LIMIT).padStart(11) +
    `${((overallTotal / TOTAL_LIMIT) * 100).toFixed(1)}%`.padStart(10) + "  " + totalResult);
  console.log(`budget check: ${passed ? "pass" : "FAIL"}\n`);
  return passed;
}

check(previousBuild, "previous build");
const newPassed = check(newBuild, "new build");

// --- 2. Regression detection: growth that stays under the limit also gets reported ---
console.log("--- comparing the two builds ---");
const previousTotals = sumByGroup(previousBuild);
const newTotals = sumByGroup(newBuild);

console.log("group".padEnd(8) + "previous".padStart(11) + "new".padStart(11) +
  "diff".padStart(11) + "ratio".padStart(9) + "  warning");
const warnings = [];
for (const [group, { tolerance }] of Object.entries(BUDGET)) {
  const a = previousTotals.get(group);
  const b = newTotals.get(group);
  const ratio = (b - a) / a;
  const exceeded = ratio > tolerance;
  if (exceeded) warnings.push(`${group}: ${(ratio * 100).toFixed(1)}% growth (tolerance ${tolerance * 100}%)`);
  console.log(group.padEnd(8) + kb(a).padStart(11) + kb(b).padStart(11) +
    `${b - a >= 0 ? "+" : ""}${((b - a) / KB).toFixed(1)} KB`.padStart(11) +
    `${ratio >= 0 ? "+" : ""}${(ratio * 100).toFixed(1)}%`.padStart(9) +
    "  " + (exceeded ? "TOLERANCE EXCEEDED" : "-"));
}

// New pieces get reported separately: because the address changes, the name comparison
// happens with the content hash stripped out.
const rootName = (name) => name.replace(/\.[0-9a-f]{4,}\./, ".");
const previousNames = new Set(previousBuild.map((p) => rootName(p.name)));
const newPieces = newBuild.filter((p) => !previousNames.has(rootName(p.name)));

console.log(`\nnew pieces: ${newPieces.map((p) => `${rootName(p.name)} (${kb(p.bytes)})`).join(", ") || "(none)"}`);
if (warnings.length === 0) console.log("regression warning: (none)");
else {
  console.log("regression warning:");
  for (const w of warnings) console.log(`  ${w}`);
}

const exit = newPassed && warnings.length === 0 ? 0 : 1;
console.log(`\nexit code: ${exit}`);
process.exitCode = exit;
```

```
--- previous build ---
group   rule            size      limit     usage  result
entry   sum         144.6 KB   170.0 KB     85.1%  pass
shared  sum          99.4 KB   120.0 KB     82.8%  pass
route   largest      37.3 KB    45.0 KB     83.0%  pass
style   sum          30.9 KB    40.0 KB     77.3%  pass
-------------------------------------------------------
TOTAL   sum         333.7 KB   360.0 KB     92.7%  pass
budget check: pass

--- new build ---
group   rule            size      limit     usage  result
entry   sum         147.8 KB   170.0 KB     86.9%  pass
shared  sum         121.5 KB   120.0 KB    101.2%  FAIL
route   largest      38.2 KB    45.0 KB     84.9%  pass
style   sum          32.2 KB    40.0 KB     80.5%  pass
-------------------------------------------------------
TOTAL   sum         378.4 KB   360.0 KB    105.1%  FAIL
budget check: FAIL

--- comparing the two builds ---
group      previous        new       diff    ratio  warning
entry      144.6 KB   147.8 KB    +3.1 KB    +2.1%  -
shared      99.4 KB   121.5 KB   +22.1 KB   +22.2%  TOLERANCE EXCEEDED
route       37.3 KB    38.2 KB    +0.9 KB    +2.3%  -
style       30.9 KB    32.2 KB    +1.3 KB    +4.1%  -

new pieces: route-report.js (17.4 KB)
regression warning:
  shared: 22.2% growth (tolerance 3%)

exit code: 1
```

The build manifests are the program's input; they are not the output of a measured
project but two lists written to demonstrate the checker's behavior.

## What the Output Says

The `aggregation` field in the budget definition carries an important distinction. For
the entry, shared, and style groups, the limit applies to the **sum**: all of these
pieces load on every page. For the route group, the limit applies to the **single
largest piece**, because the user loads only one route at a time. The wrong aggregation
rule breaks the budget unfairly when a new route gets added.

The new build fails in two places. The shared piece exceeds its own limit by one and a
bit percent; the total exceeds its limit by five percent. The real reason the total gets
exceeded is the shared piece's growth, but the newly added route piece also contributes
to the total. Reading the two lines together tells you whether the problem comes from a
single change or from accumulation.

The comparison table catches what the budget cannot. The entry piece grew by 2.1 percent
and is still well under the limit; it produces no warning. The shared piece grew by 22
percent, and because the tolerance is 3 percent, it produces a warning — this warning
would have fired even if the piece had stayed under the limit.

This is the distinction. **A budget protects an absolute limit; a tolerance protects the
rate of change.** If only the budget gets checked, a bundle growing 2 percent with every
release stays silent for months and then suddenly crosses the limit one day; by that day,
which change was responsible can no longer be found.

This is also why new pieces get reported separately. Because content-hashed names change
with every release, the comparison happens with the hash stripped out; otherwise every
piece would look new.

## The Budget's Place in the Deployment Pipeline

The checker returning an exit code is part of the design: in a validation step that runs
after the build, a nonzero exit code stops the step. The convention established in the
Shell Programming course is what carries the enforcement here.

Two decisions are required. **Where does it run?** A check that runs on every change
finds the problem before it gets merged; a check that runs only on the main branch
requires searching for the responsible change once the problem is found.

**What does it do?** The choice between a budget overage stopping the build and merely
producing a warning determines how serious the budget is. A budget that does not stop
the build turns into a target. Even so, an escape hatch is required: the limit must be
raisable deliberately, and raising it must leave a record. When the change made to the
budget file goes through review, the escape hatch gets checked too.

## Noise and False Positives

Quantity budgets are deterministic; the same input produces the same number. Metric
budgets are not, and applied directly they produce false positives.

Three precautions are required. Measurement gets taken **multiple times** and the median
gets used; a single run is not enough for a decision. Measurement conditions get
**fixed**: the same network throttle, the same device profile, a browser with no
extensions — the reproducibility conditions from the Network Panel Diagnostics lesson are
the budget's precondition here. And the threshold gets chosen **clearly larger** than the
measurement's natural variability; a tolerance that sits inside the noise produces a
check that stays permanently red and therefore gets ignored.

There is also a scope decision. A budget applies not to every page but to
representative pages: the station list page and the measurement detail page. The first
lesson showed that these two pages get judged by different metrics; their budgets, for
the same reason, get written separately.

## Summary

- A budget is a limit where something happens when it gets exceeded; a number with no
  enforcement is a target, and it does not stop things from getting worse.
- The three budget types cover each other's gaps: the metric budget measures the
  experience but is noisy, the quantity budget is deterministic but indirect, the rule
  budget protects decisions that do not reduce to a number.
- A budget group's aggregation rule changes the outcome: pieces loaded on every page get
  summed, and for optional pieces the largest one gets measured.
- An absolute limit and a growth tolerance protect different things; when only the limit
  gets checked, small and steady growth stays invisible until it reaches the limit.
- Metric budgets can only get tied to enforcement once measurement conditions are fixed
  and the median of multiple runs gets taken.

## Next Step

Performance is no longer a matter of opinion: it has defined metrics, written limits,
and a check that runs on every build. That is not the whole of the quality axis, though.
A page can load fast and still be unusable — a list that cannot get navigated with a
keyboard, a button that tells a screen reader nothing, text with insufficient contrast.
These problems do not have to be a matter of opinion either; they too have defined
metrics, measurable thresholds, and conformance levels. The next lesson takes up
accessibility standards and defines usability with the same discipline — testable
criteria.
