A fast storefront gets slow one pull request at a time: a new analytics script, a carousel library, an un-optimised hero image. A performance budget is how you stop that: explicit limits, checked automatically, that a change either passes or fails.

What goes in the budget

Four numbers, measured on your two or three most important route types (home, collection, product):

 KryeoFail line
LCP (Largest Contentful Paint)< 2.0s2.5s
INP (Interaction to Next Paint)< 200ms200ms
CLS (Cumulative Layout Shift)< 0.050.1
First-load JS (gzipped)< 120KB on critical routes170KB
Targets for a Next.js commerce storefront. Measure at the 75th percentile of real users where possible.

The JavaScript number is the leading indicator. The Web Vitals are outcomes; bundle size is a cause you can see in a diff.

Measuring it

In CI, on every pull request

Run Lighthouse against a production build of the branch. The Lighthouse CI action makes this a few lines, and assert turns the budget into a pass/fail:

// lighthouserc.json
{
  "ci": {
    "collect": {
      "url": [
        "http://localhost:3000/",
        "http://localhost:3000/collections/all",
        "http://localhost:3000/products/example"
      ],
      "numberOfRuns": 3
    },
    "assert": {
      "assertions": {
        "categories:performance": ["error", { "minScore": 0.95 }],
        "largest-contentful-paint": ["error", { "maxNumericValue": 2000 }],
        "cumulative-layout-shift": ["error", { "maxNumericValue": 0.05 }],
        "total-blocking-time": ["error", { "maxNumericValue": 200 }]
      }
    }
  }
}

Bundle size, as its own check

Lighthouse catches the symptom; a bundle check catches the cause earlier and with a clearer message. Next.js prints First Load JS per route in the build output, so fail the build if a route crosses the line:

# fail if any route's First Load JS exceeds 130 kB
next build | tee build.log
node -e '
  const lines = require("fs").readFileSync("build.log","utf8").split("\n");
  const over = lines.filter(l => /First Load JS/.test(l) === false)
    .map(l => l.match(/(\d+(?:\.\d+)?)\s*kB/))
    .filter(Boolean).map(m => parseFloat(m[1]))
    .filter(kb => kb > 130);
  if (over.length) { console.error("Route over budget:", over); process.exit(1); }
'

Real users, continuously

Lab numbers don't tell you what customers on a mid-range phone on mobile data actually get. Log field data with the web-vitals library:

// app/web-vitals.ts  (mounted once in the root layout as a Client Component)
import { onCLS, onINP, onLCP } from "web-vitals";
 
function report(metric: { name: string; value: number; id: string }) {
  navigator.sendBeacon(
    "/api/vitals",
    JSON.stringify({ ...metric, url: location.pathname }),
  );
}
 
onCLS(report);
onINP(report);
onLCP(report);

Then watch the trend in Search Console's Core Web Vitals report; that's the data Google actually ranks on.

Storefront slower than it should be?

We do performance audits against a budget like this one: a report with the specific fixes ranked by impact, not a generic Lighthouse dump.

Get an audit

Where a Next.js storefront usually loses

  • The LCP image. Not marked priority, wrong size, wrong format, or behind a client component that delays it. Use next/image with priority on the above-the-fold hero, and let it serve AVIF.
  • "use client" too high in the tree. One client component near the root pulls its whole subtree, and every library it imports, into the bundle. Push the boundary down to the leaf that actually needs interactivity.
  • Heavy client libraries. A date library, a carousel, an animation library, an analytics SDK. Each is 20–50KB. Audit them against the budget; most have a lighter alternative or aren't needed.
  • Third-party scripts. Load them with next/script and strategy="lazyOnload", or after a user interaction. A tag manager on the critical path can cost a second of INP on its own.
  • Layout shift from web fonts and images without dimensions. Set explicit width/height (or fill with a sized container), and use next/font so the font swap doesn't reflow the page.

Make it a rule, not a hope

The budget only works if it's enforced. Put the Lighthouse and bundle checks in CI as required status checks, so a PR that regresses performance can't merge without someone explicitly deciding to accept it. A budget that's a wiki page is a budget that's already been blown.

This is how we build every Next.js storefront: the budget is set during architecture and wired into CI before the first feature ships.