Skip to content

Bundle Size Budgets: How to Stop JS Bloat Before It Ships

Without a budget, JavaScript weight only goes up. Here’s how to enforce one in CI.

John Cravey with AIFounder4 min readUpdated Jul 6, 2026

Every Next.js project starts lean. Then someone adds a dependency, then another, then a third. After two years the JavaScript bundle is 3x what it was at launch, the Core Web Vitals are sliding, and nobody can quite pin down when it happened. The fix is a bundle size budget enforced in CI — set a target, fail the build when a PR exceeds it, force the conversation up front instead of after the regression ships.

Free estimate · 2 minutes

Slow pages lose rankings and leads. Price the fix.

Build the estimate to see what a fast rebuild would look like for your site and what we would ship in the first thirty days.

What to budget

  • First-load JS per route. Target: 200-300KB on most marketing routes.
  • Total JS per route. Target: 500KB max.
  • CSS per route. Target: 50-80KB.
  • Hero image per route. Target: 200KB.
  • Total HTML weight. Target: under 1MB on first load including images.

The exact targets depend on your audience. SMB sites for general consumers can sustain higher weight than developer-targeted sites where the audience expects sub-second loads. Pick a number, hold the line.

Reading the Next.js build output

`npm run build` prints a per-route table of JavaScript payloads. The columns: Route, Size (JS for this specific route), First Load JS (everything needed to render the route, including shared chunks). The First Load JS number is the one users actually download on cold visit.

Step 1: extract the budget from a passing build

Run `npm run build`. Note the First Load JS for each route. Add 10-15% headroom and that’s your budget. Lock it in.

Step 2: write a budget-check script

// scripts/check-bundle-budget.ts
import { readFileSync } from "node:fs";
import { execSync } from "node:child_process";

interface Budget { [route: string]: number; } // KB

const BUDGETS: Budget = {
  "/": 280,
  "/blog": 240,
  "/blog/[slug]": 260,
  "/contact": 220,
  "/solutions": 280,
};

const output = execSync("npm run build --silent", { encoding: "utf8" });
const lines = output.split("\n").filter((l) => l.match(/^\s*[├└]\s/));

const violations: string[] = [];
for (const line of lines) {
  // Parse route and First Load JS column
  const m = line.match(/\s+(\/\S*)\s+\S+\s+(\d+(?:\.\d+)?)\s*kB/i);
  if (!m) continue;
  const [, route, size] = m;
  const budget = BUDGETS[route];
  if (!budget) continue;
  if (parseFloat(size) > budget) {
    violations.push(`${route}: ${size}KB > ${budget}KB budget`);
  }
}

if (violations.length) {
  console.error("Bundle budget violations:");
  violations.forEach((v) => console.error("  " + v));
  process.exit(1);
}
console.log("All routes within bundle budget");

Step 3: wire to CI

In your CI workflow (GitHub Actions, Coolify build hook, etc.), run the script after the build. Failed budget = failed build = PR can’t merge. The friction forces the conversation: ‘this PR adds 60KB to /blog — is it worth it?’

Diagnosing where the weight came from

Use `@next/bundle-analyzer` or `webpack-bundle-analyzer` to visualize what’s in each chunk. Run it locally, look for: oversized libraries (anything over 50KB warrants justification), polyfills you don’t need, duplicate dependencies (the same lib appearing twice with different versions).

// next.config.ts
import bundleAnalyzer from "@next/bundle-analyzer";

const withBundleAnalyzer = bundleAnalyzer({
  enabled: process.env.ANALYZE === "true",
});

export default withBundleAnalyzer({
  // … rest of config
});

Then run: `ANALYZE=true npm run build`. Opens a visual tree map in your browser.

Common offenders

  • Date libraries. Moment.js is ~70KB; date-fns is ~12KB; day.js is ~2KB. Switch.
  • Icon libraries imported as whole packs. Import individual icons, not the whole library.
  • Form libraries. react-hook-form is ~25KB; native HTML forms + Zod validation are 0KB + ~10KB. Reconsider.
  • framer-motion. ~40KB. Native View Transitions cover most cases.
  • lodash. Full lodash is ~70KB; lodash-es with tree-shaking is much less; sometimes you don’t need it at all.
  • Polyfills. core-js can be huge if pulled in by accident. Modern browsers don’t need most polyfills in 2026.

Strategies to shrink the bundle

  1. Move client components to server components where possible.
  2. Dynamic-import heavy components that aren’t used on every page.
  3. Replace heavy libraries with lighter alternatives or native APIs.
  4. Tree-shake aggressively — use named imports, not default imports of the entire library.
  5. Remove dead code with knip or unimported.

When the budget gets blown legitimately

Sometimes a feature genuinely needs the weight (an interactive map, a chart, a video player). Two options: (1) raise the budget for that route specifically with explicit justification; (2) lazy-load the heavy component so it doesn’t affect the first-load number. Option 2 is almost always better.

Tracking the trend over time

Log the First Load JS number for each route on every deploy. Build a small dashboard (or just write to a CSV in your repo). Trend over months. If a route is creeping up 5KB per quarter, you have a slow regression. The dashboard surfaces it before it’s a problem.

How this lands across FH client work

Every new FH client build ships with a bundle budget in CI. Existing builds are migrated as part of perf work. Average First Load JS across the client book: 220KB. New PRs that exceed the budget get caught at review time, not after a CWV regression. If your site’s JS is creeping up without a budget catching it, book a consultation — the setup is a half-day engagement with permanent compounding benefit.

Answers

Frequently asked questions

What should a bundle budget cover?

The JavaScript shipped per route and the shared baseline every route pays. Those two numbers explain most performance regressions. Total page weight is worth watching too, but JavaScript is the part that costs execution time as well as transfer.

Where do I get the numbers?

From the build output, which reports per-route sizes on every build. That output is the honest scorecard and most teams never read it. Extracting the current values from a passing build is how you set a budget grounded in reality rather than aspiration.

How should a budget be enforced?

In continuous integration, failing the build when a route exceeds its limit. A budget nobody enforces is a note in a document. Enforcement is what converts it into a decision made at the moment the weight is added rather than during a later audit.

What limit should I set?

Slightly above your current passing numbers, so the budget catches regressions immediately, then tighten as you improve. Setting an aspirational limit that fails on day one produces a check everyone disables within a week.

How do I find where the weight came from?

Compare the route sizes against the previous build and look at what changed. A single route jumping is almost always a new import; the shared baseline jumping is something added to a layout or a shared component, which is the more expensive of the two.

What causes sudden bundle growth?

A client directive added high in the tree, a heavyweight library imported for one function, an icon set imported wholesale, or a date library pulled in with every locale. All four look like one line in a diff.

Does a bigger bundle always mean a slower site?

Not linearly, but consistently enough to manage. Transfer is only part of it; parsing and executing the JavaScript is what blocks the main thread on a mid-range phone. That is why the number is worth budgeting even when the connection is fast.

Should third-party scripts count against the budget?

Yes, and they are frequently the largest part. Excluding them because they are not your code measures the wrong thing: the visitor's browser makes no such distinction, and a chat widget can outweigh your entire application.

How do I reduce a bundle that is already too large?

In order: find the client directives that could move to the server, replace or drop the heaviest dependencies, split code that only some routes need, and defer third-party scripts. The first usually returns the most and costs the least.

Is code splitting always the answer?

It helps when large code is genuinely only needed on some routes or after an interaction. It does not help when the code loads on every page anyway, where splitting adds requests and complexity while shipping the same total. Remove first, split second.

How often should budgets be reviewed?

When they fail, and once a quarter otherwise. Failures are the useful signal. The quarterly review is for tightening limits after improvements, so the budget keeps ratcheting down rather than preserving whatever size the site reached.

What is the argument for this to a sceptical team?

That it converts a slow, invisible degradation into a specific conversation at the moment of the change, when the person who added the weight can still explain it. Without it, bundle size is fixed in an expensive burst once a year and grows continuously in between.

Question we did not answer? Ask us directly and we will answer it here.

John Cravey, Founder
Written by
John Cravey
Founder

Founder of Frontend Horizon. Writes most of the long-form work on the FH blog.

Newer post
Google Ads for Small Businesses: The Complete Guide to Running Campaigns That Don’t Waste Your Budget
Older post
Cloudflare Workers: When Edge Functions Actually Earn Their Keep
Keep reading

More from the blog

Next.js·6 min

Next.js 16.1 in Production: The Migration Playbook We Run on Every FH Site

Next 16.1 is the lean target. Here’s the exact migration we run, what breaks, and what to delete after.

Next.js·10 min

Speed Is a Ranking Factor: The Next.js Performance Checklist by Business Size

Google measures your speed with real users and ranks you on it. Next.js hands you the tools to win. Most sites leave them switched off.

Next.js·10 min

AGENTS.md and llms.txt: Making Your Next.js Project Legible to AI

AI reads your code and your site whether you help it or not. Two small files decide whether it reads them right.