Performance work has a predictable life cycle. Someone runs Lighthouse, posts a red screenshot, a sprint gets spent on images and bundle splitting, the number goes green, and eleven weeks later it is red again. Nothing was wrong with the work. What was missing was a mechanism.
A performance budget is that mechanism: a small number of thresholds, attached to the moment a change is made, that turn "the site feels slow" into a review comment on the pull request that caused it.
A score is not a budget
A Lighthouse score is a weighted composite, computed on a throttled emulation of a device nobody owns. It is genuinely useful for diagnosis and nearly useless as a target, for one structural reason: it is not decomposable. When it drops four points, it does not tell you which change to revert.
Budget the things a user can feel, and budget them separately:
- LCP — when the main content appears. This is what "slow" usually means.
- INP — how long the page takes to answer an interaction. This is what "janky" means, and it is mostly a main-thread problem, which means it is mostly a JavaScript problem.
- CLS — whether the layout stops moving. Usually caused by media without dimensions, late-loading fonts, or injected banners.
- JavaScript transferred on the critical path — the leading indicator for the other three. It is also the only one of the four you can check without a browser, which makes it the cheapest to enforce.
Set the thresholds slightly tighter than the number you would accept in production, so a regression is caught while it is still a regression and not yet an incident. Field data at the 75th percentile is the target you actually care about; lab data is the early warning.
Put the budget where the change happens
A dashboard nobody opens is not a guardrail. The budget has to fail a check on the pull request that broke it, while the author still remembers what they were doing and reverting costs nothing.
The two-tier setup that has worked for me:
- Per pull request: a bundle-size check on the routes that matter, comparing against the base branch. Fast, deterministic, no flakiness, runs in seconds. This one blocks merges.
- Nightly, on a deployed preview: Lighthouse CI across a handful of representative URLs, with medians over three runs. Slower and noisier, so it reports rather than blocks — but it catches the things bundle size cannot see, like a render-blocking third-party script.
The blocking check should be the boring one. If your merge gate is flaky, the team will learn to click through it, and then you have a ritual instead of a budget.
# .github/workflows/performance.yml
- name: Build and compare route payloads
run: npm run build --workspace=web
- name: Enforce budget
run: node scripts/check-route-budget.mjs
# Fails when any budgeted route grows more than 5% over the
# base branch, or exceeds its absolute ceiling in kB.Keep the budget file in the repository next to the code, reviewed like code. A raised threshold should show up in a diff with a reason attached, not in someone's memory of a decision made in a meeting.
The three regressions that actually happen
Across projects, almost every regression I have chased was one of three things.
A font arrived late
Fonts are the most common cause of both a slow LCP and a visible layout shift, because text cannot paint until the face resolves. In Next.js the fix is next/font, which self-hosts and generates a size-adjusted fallback so the swap does not move the layout:
import { IBM_Plex_Mono } from 'next/font/google';
export const mono = IBM_Plex_Mono({
subsets: ['latin'],
weight: ['300', '400', '600'],
display: 'swap',
variable: '--font-mono',
});Two rules worth enforcing: declare only the weights you actually use, and scope a font to the routes that need it. A display face loaded in the root layout for one page is downloaded on every page. Instantiating it in the layout for that route subtree keeps it off the rest of the site — a genuinely free win that is easy to miss.
A third-party script landed in the head
Analytics, chat widgets, consent banners, tag managers: each one arrives as a one-line change with no visible cost in code review, and each one can execute on the main thread before your content paints. Load them with an explicit strategy rather than by default:
import Script from 'next/script';
<Script src="https://example.com/widget.js" strategy="lazyOnload" />;Anything that is not required for the first interaction should be lazyOnload. Anything a vendor insists must be synchronous in the head deserves a conversation about what it is measuring and whether that is worth your LCP.
A client component grew a dependency
This is the one the App Router makes both better and easier to get wrong. Server components ship no JavaScript, so the boundary is where the cost lives — and a use client directive high in the tree quietly pulls everything below it into the bundle.
- Push
use clientas far down the tree as it will go. Interactive leaves, not interactive pages. - Format dates, sort, and filter on the server. A date library in a client component is pure payload.
- Load genuinely heavy, genuinely optional UI with
dynamic(() => import(...), { ssr: false })— editors, charts, map views, anything behind a modal. - Read the route payload table
next buildprints. It is the cheapest performance tool you already have, and it is per-route, which is exactly the granularity a budget needs.
Field data settles arguments that lab data starts
Lab numbers are reproducible and wrong in a specific way: they describe one device on one network. Real users are on worse hardware than your laptop and better networks than your throttling profile, and the distribution matters more than the median.
Collecting the real thing is a few lines, because the browser reports it:
import { onLCP, onINP, onCLS } from 'web-vitals';
const report = (metric: { name: string; value: number; id: string }) => {
navigator.sendBeacon('/api/vitals', JSON.stringify(metric));
};
onLCP(report);
onINP(report);
onCLS(report);Segment by device class and by route, and look at p75 rather than the average. Averages hide the users having the worst time, which are precisely the users who leave. One route being slow for one device class is a normal finding — and a much more actionable one than a single global number.
Spending the budget on purpose
A budget that can never be exceeded is a budget the business will eventually route around. Sometimes the checkout genuinely needs the heavier component, and shipping it is the right call.
Make that an explicit, recorded transaction: which route, how much, why, and what is planned to pay it back. Three lines in the pull request description is enough. The point of a budget is not that the number never moves — it is that it never moves silently.
The structural point is the same one that applies to test coverage or type safety: performance holds when the mechanism outlives the person who cared about it. Related reading: Feature-Sliced Design in a Next.js App Router codebase, which covers where the client boundary tends to land in practice.



