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.
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: 90The 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.js4. 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
- Merge the
store-baseline.ymlworkflow tomainfirst and confirm asize-baselineartifact appears under the Actions run for that push. - Open a PR that adds a deliberately heavy import (e.g. a full moment.js import with all locales) and confirm
check-size-regression.jsreports aFAILline naming the specific chunk and the percentage delta. - Confirm the workflow step exits with a non-zero code and the PR’s status check shows red — a script that logs
FAILbut forgetsprocess.exit(1)will report success anyway. - Remove the deliberate regression, push again, and confirm the same check goes green with
OKlines showing a near-zero delta. - Manually delete the
size-baselineartifact 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. - 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.jsx → AccountSettings.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.
Related
- Enforcing Performance Budgets in CI — parent cluster covering byte budgets per chunk type and the full CI gating strategy
- Configuring size-limit as a GitHub Action — sibling guide on automatic PR size comments as a complementary, faster-feedback layer
- Finding Duplicate Dependencies in a Bundle — a common root cause of the slow, multi-PR growth this gate is designed to catch
- Setting Up rollup-plugin-visualizer in Vite — treemap tooling for attributing a flagged delta to a specific dependency