Custom Performance Budgets by Route
Custom Performance Budgets by Route
The Symptom
The e-commerce platform has a global JavaScript budget of 350KB. The homepage stays well under budget at 180KB. The inventory dashboard requires charting libraries, real-time WebSocket connections, and complex table rendering, pushing it to 340KB. A developer adds a small feature to the dashboard, pushing it to 355KB, and the global budget fails. The developer raises the global budget to 400KB. Now the homepage can silently grow to 400KB without triggering a failure.
A single global budget protects everything weakly. Per-route budgets protect each page according to its actual performance profile.
The Cause
Different pages have fundamentally different performance profiles. The homepage is mostly static content with a hero image. Its LCP is dominated by image loading, not JavaScript. The checkout page is interactive, and its critical metric is INP, driven by JavaScript execution time. The inventory dashboard has a complex data grid, and its JavaScript budget must account for the charting and table libraries.
Applying the same budget to all three means the budget is either too tight for the dashboard (causing false failures) or too loose for the homepage (missing real regressions).
The Baseline
Field data from the e-commerce platform by route:
| Route | p75 LCP | p75 INP | JS Size | Total Size |
|---|---|---|---|---|
| Homepage | 2.1s | 85ms | 78 kB | 320 kB |
| Product Listing | 3.4s | 140ms | 112 kB | 480 kB |
| Product Detail | 2.8s | 110ms | 95 kB | 410 kB |
| Checkout | 1.9s | 280ms | 54 kB | 240 kB |
| Dashboard | 2.4s | 195ms | 310 kB | 520 kB |
The Fix
Define budgets per route in a structured configuration:
// performance-budgets.config.ts
interface RouteBudget {
path: string;
budgets: {
lcp: number; // milliseconds
tbt: number; // milliseconds
cls: number;
jsSize: number; // bytes, gzipped
totalSize: number; // bytes, gzipped
};
priority: "critical" | "high" | "normal";
}
export const routeBudgets: RouteBudget[] = [
{
path: "/",
priority: "critical",
budgets: {
lcp: 2000,
tbt: 150,
cls: 0.05,
jsSize: 90_000, // 90KB, tight for a mostly-static page
totalSize: 400_000,
},
},
{
path: "/category/*",
priority: "critical",
budgets: {
lcp: 2500,
tbt: 200,
cls: 0.1,
jsSize: 130_000,
totalSize: 550_000,
},
},
{
path: "/product/*",
priority: "high",
budgets: {
lcp: 2200,
tbt: 200,
cls: 0.1,
jsSize: 110_000,
totalSize: 500_000,
},
},
{
path: "/checkout",
priority: "critical",
budgets: {
lcp: 1800,
tbt: 250,
cls: 0.02, // CLS on checkout is very low tolerance
jsSize: 70_000,
totalSize: 300_000,
},
},
{
path: "/dashboard",
priority: "normal",
budgets: {
lcp: 2800,
tbt: 300,
cls: 0.1,
jsSize: 350_000, // Dashboard legitimately needs more JS
totalSize: 600_000,
},
},
];
Generate the Lighthouse CI configuration from these budgets:
// scripts/generate-lighthouserc.ts
import { routeBudgets } from "../performance-budgets.config";
interface LighthouseAssertion {
[key: string]: ["error" | "warn", { maxNumericValue: number }];
}
function generateConfig(): object {
const urls = routeBudgets.map(
(r) => `http://localhost:3000${r.path.replace("/*", "/test-item")}`,
);
// Use the tightest budgets for global assertions,
// per-URL assertions override where needed
return {
ci: {
collect: {
url: urls,
numberOfRuns: 3,
settings: {
preset: "desktop",
throttling: {
cpuSlowdownMultiplier: 4,
requestLatencyMs: 150,
downloadThroughputKbps: 1600,
uploadThroughputKbps: 750,
},
},
},
assert: {
assertMatrix: routeBudgets.map((route) => ({
matchingUrlPattern: `.*${route.path.replace("/*", "/.*")}`,
assertions: {
"largest-contentful-paint": [
route.priority === "critical" ? "error" : "warn",
{ maxNumericValue: route.budgets.lcp },
],
"total-blocking-time": [
route.priority === "critical" ? "error" : "warn",
{ maxNumericValue: route.budgets.tbt },
],
"cumulative-layout-shift": [
"error",
{ maxNumericValue: route.budgets.cls },
],
"resource-summary:script:size": [
"error",
{ maxNumericValue: route.budgets.jsSize },
],
"resource-summary:total:size": [
"error",
{ maxNumericValue: route.budgets.totalSize },
],
} as LighthouseAssertion,
})),
},
upload: {
target: "temporary-public-storage",
},
},
};
}
const config = generateConfig();
console.log(`module.exports = ${JSON.stringify(config, null, 2)};`);
For size-limit, per-route bundle tracking:
{
"size-limit": [
{
"name": "Homepage",
"path": "dist/_astro/home-*.js",
"limit": "90 kB",
"gzip": true
},
{
"name": "Product Listing",
"path": "dist/_astro/listing-*.js",
"limit": "130 kB",
"gzip": true
},
{
"name": "Checkout",
"path": "dist/_astro/checkout-*.js",
"limit": "70 kB",
"gzip": true
},
{
"name": "Dashboard",
"path": "dist/_astro/dashboard-*.js",
"limit": "350 kB",
"gzip": true
},
{
"name": "Shared vendor",
"path": "dist/_astro/vendor-*.js",
"limit": "120 kB",
"gzip": true
}
]
}
The Proof
With per-route budgets in place:
- The dashboard developer’s 15KB addition passes the dashboard budget (310KB + 15KB = 325KB < 350KB limit) without raising any global threshold.
- A 12KB addition to the homepage bundle (78KB + 12KB = 90KB, exactly at the 90KB limit) triggers a warning. The developer investigates and discovers they accidentally imported a utility from the dashboard module. Removing the import drops the homepage bundle back to 79KB.
- The checkout page CLS budget of 0.02 catches a 0.04 CLS regression caused by a late-loading payment icon, even though the global CLS budget of 0.1 would have allowed it.
In the first month of per-route budgets, the e-commerce platform caught 3 regressions that global budgets would have missed: two homepage bundle creep issues and one checkout CLS regression.
The Trade-off
Per-route budgets require maintenance. When the team adds a new page, they must define its budget. When the architecture changes (a library moves from a route bundle to a shared vendor chunk), budgets for multiple routes may need adjustment.
The budget configuration file becomes a code-reviewed artifact. Changes to budgets require justification in the PR description: why is this budget being raised? What optimization is planned to bring it back down? This administrative overhead is the cost of granular performance tracking.
The alternative, a single global budget, is simpler to maintain but weaker at catching regressions. The right choice depends on the team’s deployment frequency and the application’s performance sensitivity. For the e-commerce platform, where checkout conversion rate correlates directly with page speed, per-route budgets on critical paths are non-negotiable. For internal tools with no revenue impact, global budgets suffice.