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.

The size check inside a remote's own pipeline Running the check in each remote's pipeline means a regression fails in front of the author rather than in a shared dashboard. Where the budget check belongs Build the remote emit stats.json Measure gzipped per entry, not per file Compare to base ceiling + delta Annotate the PR entry, size, owner
The last stage is the one that changes behaviour: a message naming the entry and the owner turns a statistic into a task.

Prerequisites #

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 #

What the check fails on, and why Five independent conditions, each catching a different way a federated bundle regresses, with only the smallest one downgraded to a warning. Condition Threshold Action Entry over its ceiling absolute fail — name the entry and owner Entry grew > 10 kB in one PR delta fail — almost always accidental Entry grew > 2 kB in one PR delta warn inline on the PR Shared dep bundled locally boolean fail — the most expensive regression Waiver past its expiry date date fail — the negotiated grace ended
The fourth row catches a failure the other four cannot see, and it is usually ten times larger than any of them.

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 #

Why size checks alone miss the largest regression Duplicated dependencies distribute their cost across several entries, so every individual budget passes while the composed page fails. Size checks only Every entry is under its ceiling react ships in three remotes The page total is 260 kB over No single build ever goes red Size + duplication checks Stats file scanned for shared deps A locally-bundled react fails the build Caught in the PR that caused it Page total stays predictable
Duplication is the one regression that is invisible to every per-entry number you can measure.

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 #

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.