Skip to main content
fast frontend

Custom Performance Budgets by Route

5 min read Chapter 6 of 33

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:

Routep75 LCPp75 INPJS SizeTotal Size
Homepage2.1s85ms78 kB320 kB
Product Listing3.4s140ms112 kB480 kB
Product Detail2.8s110ms95 kB410 kB
Checkout1.9s280ms54 kB240 kB
Dashboard2.4s195ms310 kB520 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.