Failing Builds on Bundle-Size Regressions

Scenario: three PRs land over two weeks, each reviewed and approved on its own merits — a new date-range picker, a richer error-boundary component, an analytics SDK swap. Each one individually added roughly 13-14 KB to the initial bundle. None of them tripped the team’s flat “under 150 KB” budget check, because the bundle started the month at 108 KB and only crossed 148 KB after the third merge. A fourth PR that adds nothing but a one-line CSS fix finally pushes the entry chunk to 151 KB and gets blocked — and the author, who did nothing wrong, is left debugging a budget failure that three previous PRs actually caused:

$ npx size-limit
  Entry chunk (gzip)
  Size limit:   150 KB
  Size:         151.4 KB
  Loading time: 3.1s
  ✖ FAIL — entry chunk exceeds size limit by 1.4 KB

The absolute ceiling did its job eventually, but it caught the regression three PRs and roughly two weeks too late, and pinned the blame on the wrong commit. This page adds a second gate that fails the specific PR responsible for growth, by comparing every PR’s build against a stored baseline from the base branch rather than against a flat number alone.

Root cause: an absolute ceiling has no memory of where it started

A flat byte ceiling, the kind covered in enforcing performance budgets in CI, only asks “is this build under X?” It has no concept of what the build measured yesterday, so it cannot distinguish a PR that grows a healthy 100 KB bundle to 108 KB from a PR that grows an already-strained 145 KB bundle to 153 KB — the second is the one that actually deserves scrutiny, and a ceiling-only gate treats both identically until the ceiling itself is crossed. Small, individually-reasonable increases from unrelated PRs compound silently in between those check-ins, the same failure mode that shows up when duplicate dependencies creep into a bundle one transitive install at a time rather than in one obvious commit.

The fix is to store a baseline — a snapshot of what the base branch measured at its last known-good build — and diff every PR’s build against that specific number, not against a static constant written once in a config file and never revisited.

Cumulative bundle growth across four PRs against a flat ceiling A bar chart with four bars of increasing height labeled PR1 108 KB, PR2 122 KB, PR3 135 KB, PR4 148 KB, all below a dashed 150 KB ceiling line, followed by a fifth bar labeled PR5 151 KB that crosses above the ceiling line and is marked as the one that fails, even though it added the least of the five. 150 KB ceiling PR1 · 108 KB PR2 · 122 KB PR3 · 135 KB PR4 · 148 KB PR5 · 151 KB only +3 KB — blamed for all of it

Exact fix: baseline artifact plus a Node diff gate

1. Persist a baseline on every push to the base branch

# .github/workflows/store-baseline.yml — Webpack 5 / Vite 5+
name: Store size baseline
on:
  push:
    branches: [main]

jobs:
  baseline:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 20 }
      - run: npm ci
      - run: npm run build
      - name: Write size summary
        run: |
          node -e "
            const fs = require('fs');
            const files = fs.readdirSync('dist/assets').filter(f => f.endsWith('.js'));
            const summary = {};
            for (const f of files) {
              const key = f.replace(/[-.][a-f0-9]{6,10}\./, '.');
              summary[key] = fs.statSync('dist/assets/' + f).size;
            }
            fs.writeFileSync('size-baseline.json', JSON.stringify(summary, null, 2));
          "
      - uses: actions/upload-artifact@v4
        with:
          name: size-baseline
          path: size-baseline.json
          retention-days: 90

The key normalization strips the content hash out of each filename so index.a1b2c3.js on main and index.d4e5f6.js on a PR branch are recognized as the same logical chunk despite having different hashes — without this step, every single PR would appear to introduce a brand-new file with no baseline to compare against.

2. Diff the PR build against that baseline

// scripts/check-size-regression.js — Webpack 5 / Vite 5+
const fs = require('fs');

const THRESHOLD_PCT = 5;      // Fail if any chunk grows more than 5%
const THRESHOLD_ABS_KB = 10;  // ...or grows by more than 10 KB outright

function normalize(filename) {
  return filename.replace(/[-.][a-f0-9]{6,10}\./, '.');
}

function currentSizes(dir) {
  const out = {};
  for (const f of fs.readdirSync(dir).filter(f => f.endsWith('.js'))) {
    out[normalize(f)] = fs.statSync(`${dir}/${f}`).size;
  }
  return out;
}

const baseline = JSON.parse(fs.readFileSync('size-baseline.json', 'utf8'));
const current = currentSizes('dist/assets');

let failed = false;
for (const [name, baseBytes] of Object.entries(baseline)) {
  const curBytes = current[name];
  if (curBytes === undefined) continue; // Chunk removed entirely — not a regression
  const deltaBytes = curBytes - baseBytes;
  const deltaPct = (deltaBytes / baseBytes) * 100;
  const deltaKB = deltaBytes / 1024;

  if (deltaPct > THRESHOLD_PCT && deltaKB > THRESHOLD_ABS_KB) {
    console.error(
      `FAIL ${name}: +${deltaKB.toFixed(1)} KB (+${deltaPct.toFixed(1)}%) ` +
      `vs baseline ${(baseBytes / 1024).toFixed(1)} KB`
    );
    failed = true;
  } else if (deltaBytes !== 0) {
    console.log(`OK   ${name}: ${deltaBytes >= 0 ? '+' : ''}${deltaKB.toFixed(1)} KB`);
  }
}

if (failed) {
  console.error('\nOne or more chunks regressed beyond the configured threshold.');
  process.exit(1);
}
console.log('\nNo chunk exceeded the regression threshold.');

Requiring both the percentage and the absolute-KB condition (rather than either alone) prevents two common false positives: a tiny 2 KB chunk doubling in size (100% delta, but only 2 KB in absolute terms) and a huge 400 KB chunk drifting by 8 KB (a real byte increase, but under 2% relative to its own size).

3. Wire it into the PR workflow

# .github/workflows/size-regression.yml — Webpack 5 / Vite 5+
name: Size regression check
on: [pull_request]

jobs:
  check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 20 }
      - run: npm ci
      - run: npm run build
      - name: Download baseline from main
        uses: dawidd6/action-download-artifact@v6
        with:
          workflow: store-baseline.yml
          branch: main
          name: size-baseline
      - run: node scripts/check-size-regression.js

4. Add a webpack performance backstop underneath the percentage gate

The Node script above is the primary gate, but an absolute ceiling is still worth keeping as a second, independent layer in case the baseline artifact is ever missing or stale:

// webpack.config.js — Webpack 5 (secondary backstop only)
module.exports = {
  performance: {
    maxAssetSize: 512000,       // ~500 KB raw — deliberately loose; the Node script is primary
    maxEntrypointSize: 614400,
    hints: 'warning'             // Warning only — the Node gate script owns the hard failure
  }
};

Setting hints: 'warning' here rather than 'error' is intentional: this layer exists to catch a catastrophic, unmissable regression (someone accidentally bundling a 2 MB source map into the JS output) if the percentage-based script’s baseline artifact fails to download for any reason, not to duplicate the primary gate’s job.

Step-by-step verification

  1. Merge the store-baseline.yml workflow to main first and confirm a size-baseline artifact appears under the Actions run for that push.
  2. Open a PR that adds a deliberately heavy import (e.g. a full moment.js import with all locales) and confirm check-size-regression.js reports a FAIL line naming the specific chunk and the percentage delta.
  3. Confirm the workflow step exits with a non-zero code and the PR’s status check shows red — a script that logs FAIL but forgets process.exit(1) will report success anyway.
  4. Remove the deliberate regression, push again, and confirm the same check goes green with OK lines showing a near-zero delta.
  5. Manually delete the size-baseline artifact from the most recent main-branch run and re-run the PR check to confirm the download step fails loudly (rather than silently skipping the comparison) — this validates that a missing baseline is treated as a build problem to fix, not a pass-by-default condition.
  6. Cross-check the reported byte deltas against a bundle-composition report from setting up rollup-plugin-visualizer in Vite to confirm the flagged chunk’s growth corresponds to a real, attributable dependency rather than a hashing or normalization artifact.

Edge cases and gotchas

Removed chunks should not count as a 100% regression

When a route is deleted or merged into another route, its chunk disappears from the current build entirely. The diff script above explicitly skips any baseline entry with no matching current entry (if (curBytes === undefined) continue) rather than treating a missing chunk as a “-100%” change — the opposite mistake, flagging a shrinking bundle as a failure, is just as damaging to trust in the gate as missing a real regression.

Renamed chunks look identical to new chunks without careful normalization

A refactor that renames a route file (Settings.jsxAccountSettings.jsx) produces a chunk with a different name, not just a different hash — the regex-based normalize() function above only strips hashes, so a genuine rename will appear as “chunk removed + chunk added” rather than a tracked delta. This is usually the correct behavior (a renamed chunk’s size should be evaluated fresh), but it means a rename accompanied by a real size regression can slip through unflagged. Pair the automated gate with a periodic manual review of the full bundle composition for chunks that don’t map cleanly to the previous build.

Percentage thresholds need different tuning for route chunks than for the entry chunk

A 5% threshold on a 150 KB entry chunk allows roughly 7.5 KB of drift — reasonable. The same 5% threshold on a 20 KB route chunk allows only 1 KB, which is tight enough to fail on minifier non-determinism alone across two separate CI runners with slightly different Terser versions. Set a higher percentage threshold (10-15%) specifically for small chunks, or fall back to the absolute-KB condition as the effective gate for anything under roughly 40 KB, which the dual-condition check in the script above already does implicitly.


FAQ

Why use a percentage delta instead of a fixed byte threshold?

A fixed byte threshold treats a 2 KB increase on a 10 KB chunk the same as a 2 KB increase on a 200 KB chunk, even though the first is a 20% regression and the second is barely measurable. A percentage-based delta scales the gate’s sensitivity to the chunk’s own size, catching proportionally large regressions on small chunks that a flat byte gate would ignore entirely.

How do you get a reliable baseline when main is also changing?

Store the baseline as a CI artifact produced by the most recent successful build of the base branch, not by rebuilding main inside the PR’s own workflow run. Rebuilding main inside the PR workflow doubles build time and can race against a concurrent merge to main, producing a baseline that never matches what actually shipped.

Does webpack’s performance.maxAssetSize replace the need for a custom gate script?

No. maxAssetSize only enforces a flat, absolute ceiling per asset and operates on raw uncompressed bytes measured at compile time. It cannot compare against a base-branch baseline or apply a percentage-based delta, so it is best used as a coarse backstop underneath a dedicated diffing script, not as a replacement for one.