Skip to content
Back to Quality Assurance

Bundle Size

Keep the JavaScript and CSS bundles small so the page loads fast, especially on mobile and slow networks.

Why

Every byte of JavaScript must download, parse, compile, and execute before the page is interactive — and on a mobile device with a slow CPU, that takes 10x longer than on a desktop. A 500KB JavaScript bundle is 2.5 seconds of parse/compile time on a mid-range phone, and that is before any of it runs. Bundle size is not an aesthetic concern; it is the difference between a page that loads in 1s and one that loads in 5s, and users abandon slow pages. The tools to measure and control bundle size are mature (webpack-bundle-analyzer, size-limit, import-cost), and the techniques to reduce it are well-known (code splitting, tree shaking, dynamic imports). The discipline is tracking the size and treating every kilobyte as a budgeted resource.

When

Track bundle size on every PR (size-limit or bundlesize). Set a budget and block PRs that exceed it. Audit the bundle with webpack-bundle-analyzer whenever the size grows unexpectedly.

How

1. Measure the current bundle size and composition
   - webpack-bundle-analyzer or Rollup visualizer: shows every module and its size
   - Look for large dependencies, duplicate imports, and modules that should not be there
   - Track the gzipped size (what the user downloads), not just the raw size

2. Set a size budget and enforce it in CI
   - size-limit or bundlesize: fails the build if the bundle exceeds the budget
   - Set per-entry budgets: main bundle < 200KB, per-route chunk < 50KB
   - Allow small increases with a comment explaining why; block silent growth

3. Apply code splitting to reduce the initial bundle
   - Route-based splitting: each route is a separate chunk, loaded on navigation
   - Component-based splitting: heavy components (charts, editors) loaded on demand
   - Use React.lazy / dynamic import() for everything not needed on the first paint

4. Eliminate unnecessary dependencies
   - Audit imports: is moment.js (300KB) needed when date-fns (20KB) does the job?
   - Check for duplicate dependencies (two versions of the same package in the tree)
   - Use import-cost as a VS Code extension to see the size of every import as you type

5. Enable tree shaking and production optimizations
   - Ensure sideEffects: false in package.json for tree-shakeable packages
   - Use production mode (NODE_ENV=production) to enable minification and dead code elimination
   - Target modern browsers (esbuild target: es2020) to skip polyfills for old browsers
   - Serve Brotli or gzip compression at the CDN level

Example

// package.json — size-limit budget
{
  "size-limit": [
    { "path": "dist/main.js", "limit": "200 KB", "gzip": true },
    { "path": "dist/vendor.js", "limit": "150 KB", "gzip": true },
    { "path": "dist/routes/dashboard.js", "limit": "50 KB", "gzip": true }
  ]
}

// CI: .github/workflows/size.yml
name: Bundle Size Check
on: [pull_request]
jobs:
  size:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci && npm run build
      - run: npx size-limit
        # Fails if any bundle exceeds its limit

// Code splitting: route-based
import { lazy, Suspense } from "react";
const Dashboard = lazy(() => import("./routes/Dashboard")); // separate chunk
const Settings = lazy(() => import("./routes/Settings"));   // separate chunk

function App() {
  return (
    <Suspense fallback={<Spinner />}>
      <Routes>
        <Route path="/dashboard" element={<Dashboard />} />
        <Route path="/settings" element={<Settings />} />
      </Routes>
    </Suspense>
  );
}

// Replace moment.js (300KB) with date-fns (20KB, tree-shakeable)
// Before: import moment from "moment";  const formatted = moment(date).format("YYYY-MM-DD");
// After:
import { format } from "date-fns";
const formatted = format(date, "yyyy-MM-dd");

Common Problems

  • ⚠Importing an entire library (import _ from 'lodash') when only one function is used — the bundler cannot tree-shake a default import of the whole module, so 70KB of lodash ships for a single debounce.
  • ⚠No size budget in CI, so the bundle grows silently with every PR — 200KB becomes 500KB over a year, nobody notices, and the page gets slower with no single change to blame.
  • ⚠Shipping polyfills for browsers that are not in the target audience — the bundle includes 50KB of IE11 polyfills when the app is a B2B tool that only runs in evergreen Chrome.
  • ⚠Vendor and app code in a single bundle — every app change invalidates the vendor cache, so users re-download React (150KB) on every deploy instead of only the changed app code.

Best Practices

  • ✓Set a size budget with size-limit and fail CI on any breach — a budget that is not enforced is a suggestion, and suggestions are ignored under deadline pressure.
  • ✓Split the bundle into vendor (dependencies, changes rarely) and app (your code, changes often) chunks — the vendor chunk is cached long-term and only the small app chunk is re-downloaded on deploy.
  • ✓Use named imports from tree-shakeable packages (import { debounce } from 'lodash') so the bundler can eliminate the unused 69KB — or switch to lodash-es which is fully tree-shakeable.
  • ✓Analyze the bundle with webpack-bundle-analyzer whenever the size grows unexpectedly — the visualizer immediately reveals the culprit (a large dep, a duplicate import, a missed code split).