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):
| Kryeo | Fail line | |
|---|---|---|
| LCP (Largest Contentful Paint) | < 2.0s | 2.5s |
| INP (Interaction to Next Paint) | < 200ms | 200ms |
| CLS (Cumulative Layout Shift) | < 0.05 | 0.1 |
| First-load JS (gzipped) | < 120KB on critical routes | 170KB |
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. Usenext/imagewithpriorityon 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/scriptandstrategy="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(orfillwith a sized container), and usenext/fontso 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.