Setting Per-Remote JavaScript Size Budgets in CI #
A federated page gets heavier one small, reasonable pull request at a time — this page wires a per-remote size budget into CI so each of those pull requests has to justify itself before it merges.
The mechanism matters more than the numbers. A budget reported to a dashboard is a statistic; a budget that exits non-zero is a decision the author has to make while the change is still fresh. Everything below is about making the second thing cheap enough that nobody wants to switch it off. It is the enforcement half of performance budgets for federated apps.
Prerequisites #
- Node 20+ and a bundler that emits a stats file: Webpack 5 or Vite 5.
- size-limit
^11.1.0with@size-limit/file, or your CI provider’s equivalent. - Each remote publishing to its own path prefix, so attribution is unambiguous.
- A
budgets.config.jsat the repository root recording each entry, its ceiling and its owning team.
Step 1 — Emit a stats file the check can read #
Both bundlers can produce a machine-readable summary of what they built. Without one, the check has to guess which files matter.
// webpack.config.js — remote build
module.exports = {
// …
stats: { preset: 'errors-only', assets: true, chunkGroups: true },
output: {
filename: '[name].[contenthash].js',
// The prefix that makes this remote's bytes attributable.
publicPath: 'auto',
},
};
# Emit the stats JSON alongside the build, not instead of it
npx webpack --json > dist/stats.json
For Vite, rollup-plugin-visualizer in JSON mode gives the equivalent, and build.rollupOptions.output.manualChunks decides how granular the result is — see optimizing chunk splitting for remote apps.
Step 2 — Measure gzipped bytes, not raw bytes #
Raw byte counts move for reasons users never experience — a longer variable name, an added comment, a different minifier pass. Gzip (or Brotli) is what actually crosses the wire, and it is the only number worth failing a build on.
// .size-limit.js
module.exports = [
{
name: 'remote/cart — exposed entry',
path: 'dist/remoteEntry.*.js',
limit: '18 KB',
gzip: true,
},
{
name: 'remote/cart — first-load chunks',
// Everything the exposed module pulls in synchronously.
path: ['dist/remoteEntry.*.js', 'dist/exposed-*.js', 'dist/vendor-cart-*.js'],
limit: '54 KB',
gzip: true,
},
];
Splitting the entry from the first-load total is worth the extra line. The entry is the manifest and should stay tiny; the first-load total is what the budget is really about. When only the combined number is tracked, a growing entry hides inside a shrinking chunk and nobody notices until the entry is 60 kB of inlined code.
Step 3 — Fail the build, and say who owns it #
The check has to produce a message that routes. “Bundle size exceeded” sends everyone to the platform channel; naming the entry and the owning team sends one person to one file.
// scripts/check-budget.mjs
import { readFileSync } from 'node:fs';
import { gzipSync } from 'node:zlib';
import { globSync } from 'node:fs';
const budgets = (await import('../budgets.config.js')).default;
const failures = [];
for (const [entry, spec] of Object.entries(budgets.entries)) {
if (!spec.files) continue;
const bytes = globSync(spec.files)
.reduce((sum, f) => sum + gzipSync(readFileSync(f)).length, 0);
if (bytes <= spec.gzip) continue;
const over = bytes - spec.gzip;
const pct = ((over / spec.gzip) * 100).toFixed(1);
failures.push(
`${entry} is ${(bytes / 1024).toFixed(1)} kB gzipped, ` +
`${(over / 1024).toFixed(1)} kB (${pct}%) over its ${(spec.gzip / 1024).toFixed(0)} kB budget. ` +
`Owner: ${spec.owner}.`
);
}
failures.forEach((f) => console.error(`::error::${f}`));
process.exitCode = failures.length ? 1 : 0;
The ::error:: prefix is a GitHub Actions annotation, which surfaces the message inline on the pull request rather than burying it in a log. Every CI provider has an equivalent; using it is the difference between a check people read and one they re-run.
Step 4 — Compare against the base branch, not a constant #
An absolute ceiling tells an author they are over budget. A diff against the base branch tells them how much their change cost, which is the number they can actually act on.
# .github/workflows/size.yml
name: size
on: pull_request
jobs:
size:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 }
- name: Build base branch
run: |
git checkout ${{ github.base_ref }}
npm ci && npm run build
node scripts/measure.mjs > /tmp/base.json
- name: Build pull request
run: |
git checkout ${{ github.sha }}
npm ci && npm run build
node scripts/measure.mjs > /tmp/head.json
- name: Compare
run: node scripts/compare.mjs /tmp/base.json /tmp/head.json
Building twice doubles the CI time for this job, which is usually acceptable for a job measured in a couple of minutes. If it is not, cache the base measurement keyed by the base commit SHA — the base branch rarely changes between pull requests.
Step 5 — Set a delta threshold as well as a ceiling #
A ceiling only fires when it is crossed, which means an entry can grow by 40 per cent and stay green because it started with headroom. A delta threshold catches the trend.
// scripts/compare.mjs — flag both an absolute breach and a large single-PR jump
const DELTA_WARN = 2 * 1024; // annotate
const DELTA_FAIL = 10 * 1024; // block
for (const [entry, head] of Object.entries(headSizes)) {
const base = baseSizes[entry] ?? 0;
const delta = head - base;
if (delta > DELTA_FAIL) {
console.error(`::error::${entry} grew ${(delta / 1024).toFixed(1)} kB in one change`);
failed = true;
} else if (delta > DELTA_WARN) {
console.log(`::warning::${entry} grew ${(delta / 1024).toFixed(1)} kB`);
}
}
A 10 kB jump in one pull request is almost always an accidentally-bundled dependency rather than deliberate work, which is why it is worth blocking even when the entry is still under its ceiling.
Step 6 — Detect duplication, not just size #
The most expensive federated regression is not an entry growing — it is a dependency that stopped being shared and now ships twice. Size checks on individual entries miss this completely, because each entry looks normal.
// scripts/check-duplicates.mjs — read the stats file, flag any shared dep bundled locally
import { readFileSync } from 'node:fs';
const SHOULD_BE_SHARED = ['react', 'react-dom', 'react-router-dom', '@acme/design-system'];
const stats = JSON.parse(readFileSync('dist/stats.json', 'utf8'));
const bundled = new Set();
for (const mod of stats.modules ?? []) {
const match = SHOULD_BE_SHARED.find((dep) => mod.name?.includes(`node_modules/${dep}/`));
// A shared module appears in the graph as a consume-shared reference, not a real module.
if (match && !mod.name.includes('consume-shared')) bundled.add(match);
}
if (bundled.size) {
console.error(`::error::bundled locally instead of shared: ${[...bundled].join(', ')}`);
process.exitCode = 1;
}
This check catches the failure described in managing shared dependencies at runtime at build time rather than in production. It is the single highest-value check in this list, because the failure it prevents is both the largest and the most silent.
Step 7 — Make the numbers negotiable, on the record #
Budgets that cannot move get disabled. Budgets that move silently stop meaning anything. The middle path is a waiver with an expiry date, checked by the same script.
'remote/recommend': {
gzip: 60 * 1024,
owner: 'discovery',
waiver: { until: '2026-09-15', reason: 'model bundle split pending, PLAT-2841' },
},
The script treats an unexpired waiver as a pass with a warning and a hard failure once the date passes. Because the waiver lives in a reviewed file with a reason and a ticket, raising a budget becomes a decision with a name attached rather than a quiet edit in an unrelated change.
It is worth putting the budget file under the platform team’s ownership in CODEOWNERS. Teams remain free to spend their own allowance however they like; changing the allowance itself requires a second pair of eyes.
Verification #
- The check actually fails: add
import 'lodash'to a remote, push, and confirm the build goes red with the entry name and owner in the message. - Gzip is being measured: the reported number should be roughly a third of the file size on disk. If they match,
gzip: trueis missing. - The delta is right: open a pull request that changes only a comment. The delta should be zero or a handful of bytes, not hundreds.
- Duplication is caught: temporarily remove
reactfrom a remote’ssharedblock and confirm the duplicate check fires. - Waivers expire: set a waiver date in the past and confirm the build fails rather than warning.
Troubleshooting #
Symptom: the check passes locally and fails in CI.
Diagnosis: different NODE_ENV, so the local build skipped minification. Fix: run the production build in both places, and assert on the presence of minified output before measuring.
Symptom: sizes fluctuate by a few hundred bytes between identical builds.
Diagnosis: non-deterministic module ids or a timestamp baked into the bundle. Fix: set optimization.moduleIds: 'deterministic' and remove build-time timestamps; a budget check on a non-reproducible build produces noise nobody trusts.
Symptom: every pull request fails after a dependency upgrade.
Diagnosis: a transitive dependency grew and the ceiling has no headroom left. Fix: this is the budget working. Either reduce the entry or file a reviewed waiver — do not raise the number in the same commit as the upgrade.
Symptom: the duplicate check flags a dependency that is genuinely shared.
Diagnosis: the stats file records the eager fallback copy that a remote keeps for standalone use, which is expected. Fix: match only on modules reachable from the exposed entry, or exclude chunks that are never loaded when the remote runs inside a host.