Performance Budgets in Build Pipelines: Webpack, bundlesize, and Size-Limit

Performance Budgets in Build Pipelines: Webpack, bundlesize, and Size-Limit

A performance budget is a constraint: "this JavaScript bundle cannot exceed 300KB." The goal isn't to have a target to aim at—it's to make building things that exceed the target impossible. A budget that can be bypassed isn't a budget; it's a suggestion.

This guide covers enforcing bundle size budgets in CI so that exceeding them breaks the build, not just the PageSpeed score.

Why Bundle Size Is The Lever That Matters

JavaScript bundle size directly drives LCP, TTI, and TBT. More bytes = more to download, parse, and execute. On a 4G connection, 1MB of JavaScript takes about 2 seconds just to download. On 3G, closer to 8 seconds.

The problem: bundle size grows gradually and invisibly. No single PR adds 200KB. Instead, you add moment.js here (67KB), lodash there (71KB), a date picker component (45KB), an analytics SDK (38KB)—and three months later your bundle is 500KB heavier than it needs to be.

Budgets caught at PR time are cheap to fix. Budgets caught after users complain about slow load times require emergency refactoring.

Webpack Bundle Analyzer: Seeing What You're Shipping

Before enforcing budgets, understand what's in your bundle.

npm install --save-dev webpack-bundle-analyzer
// webpack.config.js
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;

module.exports = {
  plugins: [
    new BundleAnalyzerPlugin({
      analyzerMode: process.env.ANALYZE ? 'server' : 'disabled',
      openAnalyzer: true,
    }),
  ],
};
ANALYZE=true npm run build

This opens an interactive treemap showing every module in your bundle, its size (raw and gzipped), and what imported it. Common discoveries:

  • moment.js included with all locale files (400KB+) when you only need 2 locales
  • lodash imported as import _ from 'lodash' (full 71KB) instead of cherry-picks
  • Duplicate packages at different versions (React 17 and React 18 both in the bundle)
  • Dev dependencies accidentally bundled in production
  • Unused but imported CSS from a UI library

Fix these before setting your baseline. Once you understand what you're shipping, set a budget that reflects the optimized state.

bundlesize: Simple CI Enforcement

bundlesize is the simplest budget tool. It checks file sizes against limits and exits with a non-zero code if any are exceeded.

npm install --save-dev bundlesize

Add to package.json:

{
  "bundlesize": [
    {
      "path": "./dist/static/js/main.*.js",
      "maxSize": "250 kB",
      "compression": "gzip"
    },
    {
      "path": "./dist/static/js/vendor.*.js",
      "maxSize": "150 kB",
      "compression": "gzip"
    },
    {
      "path": "./dist/static/css/main.*.css",
      "maxSize": "30 kB",
      "compression": "gzip"
    }
  ]
}

Add to CI:

- name: Check bundle sizes
  run: npx bundlesize

Output on failure:

FAIL  ./dist/static/js/main.abc123.js: 276.4 kB > maxSize 250 kB (gzip)

The path pattern uses glob — make sure it matches your actual build output. Run ls dist/static/js/ to verify.

bundlesize also has a GitHub integration that posts size comparison comments on PRs:

{
  "bundlesize": [
    {
      "path": "./dist/*.js",
      "maxSize": "300 kB"
    }
  ],
  "scripts": {
    "bundlesize": "bundlesize"
  }
}

Set BUNDLESIZE_GITHUB_TOKEN in your CI secrets to enable PR comments showing size diffs between branches.

size-limit: Smarter Budget Enforcement

size-limit goes beyond raw file size—it can simulate the actual import cost, including tree-shaking:

npm install --save-dev @size-limit/preset-app
// package.json
{
  "size-limit": [
    {
      "path": "dist/static/js/main.*.js",
      "limit": "250 KB",
      "gzip": true
    },
    {
      "path": "dist/static/js/vendor.*.js",
      "limit": "150 KB"
    },
    {
      "name": "Full app (main + vendor)",
      "path": [
        "dist/static/js/main.*.js",
        "dist/static/js/vendor.*.js"
      ],
      "limit": "380 KB",
      "import": {
        "index.js": "{ App }"
      }
    }
  ]
}
npx size-limit

size-limit can also measure import time (how long the JS takes to parse and execute on a mid-range mobile CPU):

{
  "size-limit": [
    {
      "path": "dist/index.js",
      "limit": "200 KB",
      "time": "200 ms"  // Parse+execute time limit
    }
  ]
}

This catches libraries that are small but expensive to parse (some polyfills, certain templating engines).

Webpack Performance Hints

Webpack has built-in size hints that fail the build above a threshold:

// webpack.config.js
module.exports = {
  performance: {
    hints: process.env.NODE_ENV === 'production' ? 'error' : 'warning',
    maxEntrypointSize: 512000,  // 500 KB
    maxAssetSize: 512000,
    
    // Custom filter — only check JS and CSS
    assetFilter: function(assetFilename) {
      return /\.(js|css)$/.test(assetFilename);
    },
  },
};

With hints: 'error', the Webpack build exits with code 1 if thresholds are exceeded. This is the bluntest approach—straightforward to set up, no additional tools needed.

The downside: Webpack's hints fire on individual assets, not on named logical chunks. If you have 10 small JS files that add up to 800KB total, none of them individually exceeds 512KB, and Webpack stays silent.

Combine Webpack hints (per-asset enforcement) with bundlesize (total budget enforcement).

Code Splitting: The Foundation of Budget Compliance

You can't hit a 250KB budget for your initial bundle if you ship 400KB of code. Code splitting is how you stay within budget while shipping features.

// Before: everything imported at startup
import { HeavyDashboard } from './Dashboard';
import { ReportingModule } from './Reporting';
import { AdminPanel } from './AdminPanel';

// After: lazy-load non-critical routes
import { lazy, Suspense } from 'react';

const HeavyDashboard = lazy(() => import('./Dashboard'));
const ReportingModule = lazy(() => import('./Reporting'));
const AdminPanel = lazy(() => import('./AdminPanel'));

function App() {
  return (
    <Suspense fallback={<Loading />}>
      <Routes>
        <Route path="/dashboard" element={<HeavyDashboard />} />
        <Route path="/reports" element={<ReportingModule />} />
        <Route path="/admin" element={<AdminPanel />} />
      </Routes>
    </Suspense>
  );
}

Now the initial bundle only contains routing logic and the landing page. Dashboard, Reporting, and Admin code loads on demand.

Verify the split worked:

npm run build -- --stats
# Check that Dashboard, Reporting, AdminPanel are separate chunks in the stats output

Tracking Bundle History

Point-in-time enforcement catches regressions in the current PR. Historical tracking shows gradual drift across many PRs.

Use bundlewatch (a maintained fork of bundlesize with historical tracking):

npm install --save-dev bundlewatch
{
  "bundlewatch": {
    "files": [
      {
        "path": "./dist/**/*.js",
        "maxSize": "300kB"
      }
    ],
    "defaultCompression": "gzip",
    "ci": {
      "trackBranches": ["main"],
      "repoBranchBase": "main"
    }
  }
}

bundlewatch posts a GitHub status check with size comparison versus the base branch:

bundle-watch: main.js 245kB (+12kB vs main)

This makes bundle growth visible at PR review time, even when it's not over budget yet.

Handling Third-Party Dependencies

The hardest budget problem: a marketing team adds a chat widget and suddenly your bundle is 150KB heavier.

Two approaches:

Exclude third-party scripts from your bundle entirely:

<!-- Load third-party scripts after the app, with async/defer -->
<script async src="https://widget.intercom.com/widget.js"></script>

Third-party scripts loaded via <script> tags outside your bundle don't count against your bundle size budget. They still affect performance (they block the main thread), but at least the budget stays clean.

Use Partytown to run third-party scripts in a web worker:

npm install @builder.io/partytown
<script>
  partytown = {
    lib: '/~partytown/',
    forward: ['dataLayer.push', 'fbq'],
  };
</script>
<script type="text/partytown" src="https://www.googletagmanager.com/gtag/js"></script>

Partytown moves third-party JavaScript to a web worker, preventing it from blocking the main thread. TBT and INP improve significantly for marketing-heavy pages.

Full CI Pipeline Setup

# .github/workflows/bundle-size.yml
name: Bundle Size Check

on:
  pull_request:
    branches: [main]

jobs:
  bundle-size:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
      
      - name: Install dependencies
        run: npm ci
      
      - name: Build production bundle
        run: npm run build
        env:
          NODE_ENV: production
      
      - name: Run size-limit
        run: npx size-limit --json > size-limit-results.json
        
      - name: Fail if over budget
        run: |
          # size-limit exits with code 1 if over budget
          # The previous step already handles this, but be explicit
          if npx size-limit 2>&1 | grep -q "exceeded"; then
            echo "Bundle size budget exceeded"
            exit 1
          fi
      
      - name: Upload bundle stats
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: bundle-stats-${{ github.sha }}
          path: |
            dist/stats.json
            size-limit-results.json
          retention-days: 30
      
      - name: Comment bundle size on PR
        if: github.event_name == 'pull_request'
        uses: actions/github-script@v7
        with:
          script: |
            const fs = require('fs');
            const results = JSON.parse(fs.readFileSync('size-limit-results.json', 'utf8'));
            
            const table = results.map(r => 
              `| ${r.name} | ${r.size} | ${r.passed ? '✅' : '❌'} |`
            ).join('\n');
            
            github.rest.issues.createComment({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
              body: `## Bundle Size Report\n\n| Chunk | Size | Status |\n|---|---|---|\n${table}`
            });

What Budget Numbers to Set

The right budgets depend on your performance goals, but here's a starting point for a React SPA:

Asset Budget (gzip) Rationale
Main JS chunk 150 KB Critical path; user waits for this
Vendor/framework chunk 100 KB React + React DOM compressed
Route chunks 50 KB each Lazy-loaded; shouldn't be too large
Total initial JS 300 KB Google's "Good" threshold assumption
CSS 30 KB Rarely the bottleneck
Total page weight 1 MB Including images (highly site-specific)

Set your initial budgets at 120% of your current sizes—this gives you breathing room without rewarding bloat. Then ratchet them down after each optimization sprint.


Performance budgets work when they're enforced automatically, not when they're aspirational numbers in a Google Doc. Webpack hints, bundlesize, and size-limit are all straightforward to add to any build pipeline. The five minutes to set this up prevents the multi-day refactoring session three months from now when you realize your LCP is 4 seconds because your bundle grew 600KB since launch.

Read more

Start now free