Skip to content
Shenzhen · The Greater Bay Area · Earth

Core Web Vitals Budgets Are Only Real When CI Fails the Build

A budget that reports is a preference and a budget that blocks a merge is a constraint. This is the enforcement layer I keep on Next.js projects: route weight read from the build manifest, five-sample Lighthouse assertions, and a weekly field query for the metric CI cannot see.

8 min read1,718 words
Web PerformanceNext.js ArchitectureNot yet translated.

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.

MetricThresholdUnitWhere it is measuredGate behaviour
LCP2,500ms, p75Lab: median of 5 Lighthouse runs on a production build; field for truthFails the build
CLS0.10unitless, p75Lab and fieldFails the build
INP200ms, p75Field only; no lab equivalentWeekly alert, never a build failure
TBT200msLab, as a weak INP proxyWarns
Route first-load JSper routekB gzip.next/app-build-manifest.jsonFails the build
Third-party originsrationed per vendorkB and request countBuild manifest plus network logFails 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 modeWhat it looks likeFix
Budget set too tight on day oneLCP is met by lazy-loading below-fold content and deferring hydration; the build goes green and INP gets worseStart at the measurement plus 30 percent, then tighten deliberately
Single-sample gateRed on unrelated pull requests within a month; the step is disabled or made non-blockingMedian of 5 runs; error only on low-variance metrics
Budget on the wrong pagePasses on a logged-out synthetic while the authenticated page that carries the traffic is slowSeed a session and budget the real route, or state plainly that you are not
Silent baseline bumpThe threshold moves up in the same pull request that caused the regressionA 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 /dashboard measures 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

More in Engineering

Ready to build a system?[ Book a Call ]