Skip to content
academia.sh

Lesson 06 / 27

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.

Contents

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.

// 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.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close