Skip to content

Lesson 4 of 9 · 25m

JavaScript Bundle Budgets

Why JavaScript is the most expensive byte you ship, how to see what's in the bundle, and how to set budgets that fail the build instead of relying on good intentions.

A 200 KB JPEG and 200 KB of JavaScript cost the same to download and wildly different amounts to use. The image is decoded off the main thread and painted. The script has to be parsed, compiled, and executed — on the main thread, on a phone whose CPU is a quarter the speed of your laptop. Every kilobyte of JS hurts LCP if it's render-blocking or does the rendering, and hurts INP by keeping the main thread busy when the user first tries to tap something.

Nobody ever decides to ship 1.4 MB of JavaScript. It accumulates one reasonable-looking npm install at a time. The only defence that works is a budget enforced by a machine.

See what you're actually shipping

# Vite / Rollup: generate a treemap of the production build
npx vite-bundle-visualizer

# Any bundler that emits source maps
npx source-map-explorer 'dist/assets/*.js' --html reports/bundle.html

# webpack
npx webpack --profile --json > stats.json && npx webpack-bundle-analyzer stats.json

What you typically find on day one: two copies of a library at different versions, a full icon set imported for six icons, moment with every locale, a charting library pulled into the initial chunk by one admin-only import, and polyfills for browsers you dropped years ago.

Set the budget from the metric, not from the current size

Work backward. If the target is LCP ≤ 2.5 s on a mid-range phone over 4G, and your HTML, CSS, and hero image already use most of that, the JavaScript that must execute before the page is usable has a few hundred milliseconds of CPU at most. In practice that lands most teams at roughly 150–200 KB compressed of critical-path JS for the initial route. Pick a number, write it down, and make it a gate.

{
  "scripts": { "size": "size-limit" },
  "size-limit": [
    { "name": "initial route", "path": "dist/assets/index-*.js", "limit": "170 kB" },
    { "name": "checkout chunk", "path": "dist/assets/checkout-*.js", "limit": "60 kB" }
  ]
}

size-limit measures the compressed size and fails CI when a PR crosses the line — and the PR author sees exactly which commit did it. webpack users get a cruder version built in:

// webpack.config.js
module.exports = {
  performance: {
    hints: "error",
    maxEntrypointSize: 250000,   // bytes, uncompressed
    maxAssetSize: 250000,
  },
};

The four moves that shrink bundles

  1. Split by route and by interaction. import() the editor when the user clicks "Edit," not at boot. Route-level splitting is the floor, not the ceiling.
  2. Replace, don't trim. date-fns or Intl.DateTimeFormat instead of moment; native fetch instead of an HTTP client; CSS instead of an animation library.
  3. Fix tree-shaking blockers. CommonJS packages, side-effectful modules without "sideEffects": false, and barrel files that re-export everything can defeat dead-code elimination.
  4. Ship modern syntax. If your browserslist still targets IE-era browsers, you're shipping transpiled generators and polyfills to everyone.

Field notes

  • Budget the compressed size of what executes on the initial route. Total-site JS is a vanity number; lazy chunks nobody loads don't hurt.
  • A budget without an owner gets raised every time it's hit. Require a named approver to bump the limit.
  • Brotli beats gzip by 15–20% on JS. Confirm your CDN actually serves content-encoding: br — check the response headers, not the config.
  • Watch for third-party tags loaded by a tag manager: they never touch your bundler, so your budget can't see them. Track them separately.