Why does a budget that does not fail the build not exist?
A Core Web Vitals budget is real only at the moment a CI job exits non-zero and blocks a merge. Every stage before that exit code — the dashboard, the weekly report, the Lighthouse comment nobody blocks a merge on — produces a preference rather than a constraint, and preferences lose to deadline pressure at the first release that matters. I have watched this play out in three codebases with the same shape: the thresholds were documented, the measurement ran on every pull request, and the regression shipped anyway because nothing in the pipeline could say no.
A performance budget that CI does not enforce is not a budget; it is a preference, and the regression it fails to stop becomes the baseline every later measurement is compared against.
That second clause is the part teams underestimate. A regression is rarely a visible break. A vendor loader adds 38 kB of gzip to every route, p75 LCP on mid-range Android moves from 1.9s to 2.3s, no test fails, and within two sprints the new number is simply what the site does. Whoever sets a budget next measures the current build, because measuring the current build is the only honest starting point, and the degraded state becomes the reference. Performance debt has the same accounting shape as every other deferred decision: the cost repeats rather than arriving once, which is why the cost of delaying a decision by a quarter is the right comparison to make rather than a single remediation invoice.
The mechanism itself stays small. One file that states numbers, one script that measures, one CI step that fails. Everything else is reporting.
What should the budget measure, and where can each metric actually be measured?
Confusing lab and field is the most common cause of a gate that either cannot fail or fails constantly. LCP and CLS exist in both environments. INP exists only in the field, because it is derived from real interaction latency. Lighthouse's lab proxy for INP is Total Blocking Time, and TBT is a loose enough proxy that asserting on it as if it were INP will fail builds for reasons no user experiences.
| Metric | Threshold | Unit | Where it is measured | Gate behaviour |
|---|---|---|---|---|
| LCP | 2,500 | ms, p75 | Lab: median of 5 Lighthouse runs on a production build; field for truth | Fails the build |
| CLS | 0.10 | unitless, p75 | Lab and field | Fails the build |
| INP | 200 | ms, p75 | Field only; no lab equivalent | Weekly alert, never a build failure |
| TBT | 200 | ms | Lab, as a weak INP proxy | Warns |
| Route first-load JS | per route | kB gzip | .next/app-build-manifest.json | Fails the build |
| Third-party origins | rationed per vendor | kB and request count | Build manifest plus network log | Fails the build |
The rule I apply: only metrics with low lab variance are allowed to fail a build. LCP and CLS from five samples on a production build are stable enough to gate on. TBT and anything derived from third-party timing are not, so they warn. INP is enforced from field data on a slower schedule, which the fourth section covers.
How do you measure route weight from the Next.js build output?
Timing metrics tell you the site is slow. They rarely tell you which commit made it slow. Route weight does, and on an App Router project you do not need a browser for it, because the build writes the chunk list per route into .next/app-build-manifest.json.
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { gzipSync } from 'node:zlib';
interface Budget {
/** Route as it appears in the build manifest. */
route: string;
maxFirstLoadKb: number;
}
const BUDGETS: Budget[] = [
{ route: '/', maxFirstLoadKb: 170 },
{ route: '/services', maxFirstLoadKb: 150 },
{ route: '/blog/[slug]', maxFirstLoadKb: 205 },
];
const manifest = JSON.parse(
readFileSync('.next/app-build-manifest.json', 'utf8'),
) as { pages: Record<string, string[]> };
function chunksFor(route: string): string[] {
const keys = route === '/' ? ['/page', '/'] : [`${route}/page`, route];
const key = keys.find((candidate) => manifest.pages[candidate] !== undefined);
if (key === undefined) {
throw new Error(`no build manifest entry for ${route}; tried ${keys.join(', ')}`);
}
return manifest.pages[key];
}
function firstLoadKb(files: string[]): number {
const bytes = files.reduce(
(total, file) => total + gzipSync(readFileSync(join('.next', file))).byteLength,
0,
);
return bytes / 1024;
}
const violations = BUDGETS.flatMap(({ route, maxFirstLoadKb }) => {
const kb = firstLoadKb(chunksFor(route));
return kb > maxFirstLoadKb
? [`${route}: ${kb.toFixed(1)} kB gzip, budget ${maxFirstLoadKb} kB`]
: [];
});
if (violations.length > 0) {
console.error(`Performance budget exceeded:\n${violations.join('\n')}`);
process.exit(1);
}
Two details decide whether those numbers mean anything. Compress each file separately and sum, rather than gzipping the concatenation: the browser downloads and decompresses each response independently, so a concatenated figure is systematically smaller than what a user pays. And budget against a measured baseline plus headroom instead of a round number. The marketing route on this site measured 128 kB gzip after the first production build, so its budget is 170 kB, which is that figure plus roughly 30 percent. A budget of 200 kB chosen because it looks tidy would have permitted 72 kB of accidental growth before anything failed.
The manifest keys are build-internal identifiers and they differ between static and dynamic routes, so chunksFor resolves the candidates explicitly and throws when none match. A Next.js upgrade that renames a key then fails loudly at the point of measurement instead of silently passing with zero bytes measured. A budget check that cannot find its input and reports success is worse than no check.
How do you turn a noisy lab measurement into a gate that fails for the right reason?
A flaky gate is a dead gate. The lifecycle is predictable: a shared runner produces one anomalous run, a red build blocks an unrelated release, someone deletes the step or sets continue-on-error: true, and the budget stops existing. The instrument has to be quiet enough that red means something.
{
"ci": {
"collect": {
"url": [
"http://localhost:3000/",
"http://localhost:3000/services",
"http://localhost:3000/blog/geo-for-engineers"
],
"numberOfRuns": 5,
"startServerCommand": "pnpm next start",
"settings": { "preset": "desktop", "throttlingMethod": "simulate" }
},
"assert": {
"assertions": {
"largest-contentful-paint": ["error", { "maxNumericValue": 2500 }],
"cumulative-layout-shift": ["error", { "maxNumericValue": 0.1 }],
"total-blocking-time": ["warn", { "maxNumericValue": 200 }],
"categories:performance": ["warn", { "minScore": 0.9 }],
"unused-javascript": ["off", {}]
}
},
"upload": { "target": "temporary-public-storage" }
}
}
LHCI collects five runs and asserts against the median rather than the first sample, which removes most of the single-run noise that starts the disable-the-step spiral. The two errors are the metrics that survive a noisy machine; everything else warns. unused-javascript is off, and that is not the same decision as raising the LCP threshold. Switching off a lab-only audit that never reflects a shipped change is scoped and reviewable in the config; raising a threshold to obtain a green build is the failure mode this article is about.
Run against next build && next start, never next dev. Development builds are unminified, load React in development mode, and produce numbers that are meaningless in a way that happens to be reassuring.
The taxonomy of how these gates die is worth keeping in the same file as the thresholds, because each row is a decision someone will make under pressure:
| Failure mode | What it looks like | Fix |
|---|---|---|
| Budget set too tight on day one | LCP is met by lazy-loading below-fold content and deferring hydration; the build goes green and INP gets worse | Start at the measurement plus 30 percent, then tighten deliberately |
| Single-sample gate | Red on unrelated pull requests within a month; the step is disabled or made non-blocking | Median of 5 runs; error only on low-variance metrics |
| Budget on the wrong page | Passes on a logged-out synthetic while the authenticated page that carries the traffic is slow | Seed a session and budget the real route, or state plainly that you are not |
| Silent baseline bump | The threshold moves up in the same pull request that caused the regression | A budget change is its own pull request with a written reason |
How do you stop the baseline from drifting a few kilobytes at a time?
The gate protects the merge. It does not protect the threshold, and the threshold is the line that gets edited on a Friday afternoon. Two rules handle it. A budget change is its own pull request with a stated reason, never a line edited inside a feature branch. And field data runs a slower second gate that CI cannot see: real users, real devices, aggregated per day.
CREATE TABLE web_vitals_daily (
day date NOT NULL,
route text NOT NULL,
metric text NOT NULL CHECK (metric IN ('lcp', 'cls', 'inp')),
p75 numeric NOT NULL,
samples integer NOT NULL CHECK (samples > 0),
PRIMARY KEY (day, route, metric)
);
-- Routes that breached the good threshold on every day of the window.
-- One bad day is campaign traffic; seven is a regression that shipped.
SELECT route,
metric,
round(max(p75), 1) AS worst_p75,
sum(samples) AS samples
FROM web_vitals_daily
WHERE day > current_date - 8
AND ((metric = 'lcp' AND p75 > 2500)
OR (metric = 'cls' AND p75 > 0.1)
OR (metric = 'inp' AND p75 > 200))
GROUP BY route, metric
HAVING count(*) >= 7
ORDER BY worst_p75 DESC;
At 40 routes and three metrics that table collects about 120 rows a day, or roughly 44,000 rows a year, which is not a data problem at any scale Postgres cares about. The window requirement is what makes it usable: a threshold breached on one day is usually a campaign, a crawler fleet or a CDN edge having a bad afternoon, and paging on it trains the team to ignore the alert. Seven consecutive days is a change in the product.
The INP row is the specific reason this second gate exists. INP cannot be produced by a lab run, so it cannot be protected by the build gate at all. It can only be defended with field p75 and a rule that a route appearing in this query twice in a month gets the same treatment as a red build.
When is a hard CI budget the wrong instrument?
Four cases where I would not do this, or would do a reduced version of it:
- Authenticated surfaces. CI measures a logged-out synthetic request, so a budget on
/dashboardmeasures the redirect. Seeding a session for the synthetic run costs real setup time; if you will not pay it, budget a public route and admit which one you are protecting. - A product whose page shape changes weekly. Kilobyte budgets during heavy feature work punish the work rather than the waste. Budget interaction latency and LCP on the primary route until the layout stabilises, then add weight budgets.
- Embeds you do not control. A map, a video player or a chat widget will breach any total you set. Give each vendor a named ration in the budget file, so the number that fails belongs to the vendor rather than to the application.
- Sites with no pipeline at all. A pre-push hook running one Lighthouse pass against a threshold set 1.5x above the baseline beats a pipeline nobody maintains. It is a weaker gate that survives, which is the trade I make for small projects.
There is also a cost worth naming: this apparatus is roughly one engineer-day to build and a few minutes of CI on each pull request. That is cheap, but it is not free, and if the team will not spend it then the honest position is that performance is being observed rather than enforced. Any plan that assumes a fast site is standing on an assumption, not a gate.
What is the smallest version worth shipping this week?
Measure the current production build on the three routes that carry your traffic, write the numbers into a file, and set thresholds at the measurement plus 25 to 30 percent. Then wire three things in this order: the manifest check for route weight, a five-sample Lighthouse run that errors on LCP and CLS while warning on TBT, and a rule that a budget change cannot arrive in the same pull request as the code that needs it. That is an afternoon of work in exchange for a gate that fails one real regression, which is a better return than a dashboard that reports a hundred of them. The first time it turns a merge red, the number it protects has already stopped being negotiable.
Keep reading
- Static by Default: Next.js App Router Rendering Strategies2026-03-159 minEngineering
- GEO for Engineers: Making Your Site Legible to Language Models2026-03-216 minEngineering
- Structured Data for AI Search: The Entity Graph Matters More Than the Markup2026-03-098 minEngineering
- A Streaming UI for Long-Running Tasks Must Report Job State, Not Elapsed Time2026-02-228 minEngineering
- Your Dashboard Query Is Not Missing a Postgres Index: It Is Fetching 58,412 Rows to Render Twelve2026-02-169 minEngineering