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 EleviFounder4 min read

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.

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.

Written by
John Cravey
Founder

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

Newer post
ISR, SSG, SSR, and Edge: Picking the Right Rendering Mode for Each Page
Older post
Google Ads for Small Businesses: The Complete Guide to Running Campaigns That Don’t Waste Your Budget
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.

Cloudflare·6 min

Cloudflare DNS and CDN: The Base Configuration for Every FH Client Site

Every FH site sits behind Cloudflare. Here’s the exact configuration and why each setting is where it is.

Next.js·4 min

Server Components vs Client Components: The Mental Model That Stops You Reaching for ‘use client’

Most teams add ‘use client’ because they’re scared. The bundle pays for it.