Blog
Practical notes on design, product, and shipping for startups.
Practical notes on design, product, and shipping for startups.
Set a performance budget for a Next.js app used worldwide: Core Web Vitals targets, JavaScript and image size caps, testing on throttled mobile, Lighthouse CI on every pull request, and the App Router changes that save the most on slow connections. Includes config and code.
To make a Next.js app fast on slow networks, set a performance budget before you build: a cap on JavaScript per route, image weight, and requests, plus Core Web Vitals targets measured on a throttled mid-range phone. Then enforce it in CI with Lighthouse and a bundle check, so every pull request has to stay inside the budget.
Last updated: October 29, 2026.
If your startup sells worldwide, many of your users are not on fibre and a new laptop. They are on a mid-range Android phone, on mobile data, sometimes far from the nearest server. A page that feels instant on your office Wi-Fi can take several seconds to become usable for them, and on a phone the slow part is often parsing and running JavaScript, not just downloading it.
A performance budget turns "make it fast" into numbers that a build can pass or fail. Without one, every new library, tracking script, and hero image is a small reasonable decision that adds up to a slow app nobody chose.
Use two kinds of limits: user-facing timings (what people feel) and resource sizes (what you control directly). The Core Web Vitals thresholds below are Google's published "good" thresholds. The size numbers are starting points we suggest, not universal rules, so tighten or loosen them after you measure your own app.
| Metric | Target | Type | Why it matters on slow networks |
|---|---|---|---|
| Largest Contentful Paint (LCP) | 2.5 s or less | Core Web Vital | When the main content appears |
| Interaction to Next Paint (INP) | 200 ms or less | Core Web Vital | How quickly taps and clicks respond, mostly a JavaScript cost |
| Cumulative Layout Shift (CLS) | 0.1 or less | Core Web Vital | Late images and fonts push content around |
| JavaScript per route (compressed) | Suggested start: about 150 to 200 KB | Size | Every KB is downloaded, parsed, and executed on a slow CPU |
| Hero image | Suggested start: under about 100 KB | Size | Often the LCP element |
| Web fonts | One or two files, subset | Size | Fonts can block text from showing |
| Third-party scripts | Each one justified in writing | Count | Chat widgets and trackers are common budget killers |
Measure the timings at the 75th percentile of real users where you can, because averages hide the people on the slowest connections.
next build then next start), never the dev server, which is unoptimized.web-vitals library report what actual visitors experience, split by country and device.Put the budget in the repo so it is reviewed like code. Lighthouse CI can fail a build when a page goes over its targets.
// lighthouserc.js
module.exports = {
ci: {
collect: {
startServerCommand: "npm run start",
url: ["http://localhost:3000/", "http://localhost:3000/pricing"],
numberOfRuns: 3,
settings: { preset: "mobile" },
},
assert: {
assertions: {
"largest-contentful-paint": ["error", { maxNumericValue: 2500 }],
"cumulative-layout-shift": ["error", { maxNumericValue: 0.1 }],
"total-blocking-time": ["warn", { maxNumericValue: 300 }],
"resource-summary:script:size": ["error", { maxNumericValue: 200000 }],
"resource-summary:third-party:count": ["warn", { maxNumericValue: 5 }],
},
},
},
};Lighthouse runs in a lab, so it uses Total Blocking Time as a stand-in for INP. Keep INP itself for real-user monitoring.
Then run it on every pull request:
# .github/workflows/perf-budget.yml
name: perf-budget
on: pull_request
jobs:
lighthouse:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- run: npm ci
- run: npm run build
- run: npx @lhci/cli autorunTo see what is inside each bundle, add @next/bundle-analyzer and run it whenever the JavaScript budget fails. It usually points to one large dependency imported on every page.
"use client" only to the small interactive pieces, not to whole pages.next/dynamic so they download after the main content, or only when the user opens them.next/image with real sizes. Set sizes so phones get small files, and mark only the LCP image with priority.next/font. It self-hosts fonts and avoids layout shift. Load one family with the weights you actually use.next/script using strategy="lazyOnload", and question whether each one is needed at all.loading.tsx and Suspense lets the page shell appear while slower data loads.A small example of the first two points:
// app/reports/page.tsx (Server Component by default)
import dynamic from "next/dynamic";
import { getReport } from "@/lib/reports";
const Chart = dynamic(() => import("./chart"), {
loading: () => <p>Loading chart...</p>,
});
export default async function ReportsPage() {
const report = await getReport(); // runs on the server, no client JS
return (
<main>
<h1>{report.title}</h1>
<p>{report.summary}</p>
<Chart points={report.points} />
</main>
);
}next/dynamic or swap for lighter options.next/image or the CMS's own resizing.Luminix Studio builds web and mobile apps, and the AI automations around them, for startups worldwide. We treat performance as a budget that is set during scoping and checked in CI, not a cleanup sprint after launch, because users on slow networks are often the ones a growing startup is trying to reach next. If you want a Next.js app that stays fast for users everywhere, or an audit of one you already have, see luminixstudio.com.