Performance Budgets for Federated Apps #
A federated page has no single owner of its performance, which is exactly why it gets slower. Every team measures its own remote in isolation, every remote looks acceptable, and the composed page quietly drifts past three seconds to interactive. This guide covers how to set per-remote budgets, wire them into CI, and keep them honest once several teams are shipping against them.
It sits under Core Micro-Frontend Architecture Tradeoffs and connects to the work on avoiding bundle duplication, because duplication is the single largest source of budget overrun. Its step-by-step companions cover measuring time to interactive with remote modules, preloading and prefetching remote entry files, setting per-remote JavaScript size budgets in CI, and lazy loading remotes below the fold.
What breaks without a budget #
In a monolith, a performance regression has one owner and shows up in one pull request. Federation removes both properties. The host team owns Largest Contentful Paint but not the code that delays it; the remote teams own the code but see it only in a standalone harness where it loads alone, warm, and without competing for the main thread.
The specific failure mode is additive. Each remote adds 30 to 60 kB of gzipped JavaScript. None of those additions is unreasonable on its own, and none of them will fail a review. But five of them arrive on the same route, all parsed on the same main thread, and Total Blocking Time crosses the threshold where interaction feels broken. Nobody caused it, so nobody fixes it.
Three things make this worse than the equivalent monolith regression:
- The composed page is nobody’s test target. Remote CI builds the remote. Host CI builds the host. The page a user actually loads exists only in production.
- Duplicated dependencies are invisible locally. A remote that bundles its own copy of a date library looks fine alone. The duplicate only appears when it lands next to a host that already shipped one.
- Budgets without ownership do not hold. A shared “the page must be under 400 kB” number gives every team a reason to believe someone else should cut first.
Key objectives #
- Give each remote its own number, so a regression has exactly one owner and one pull request to fix.
- Measure the composed page, not the standalone harness, because the composed page is what users get.
- Fail the build, not a dashboard — a budget that only produces a warning becomes a warning everybody scrolls past.
- Budget main-thread time as well as bytes, since parse and execute cost is what turns a large bundle into an unresponsive page.
- Leave headroom deliberately, so the next team to onboard has room to ship without renegotiating everyone’s limits.
Setting the numbers #
Start from the user-facing target and work backwards, rather than from what the current bundles happen to weigh. On a mid-tier phone over a 4G connection, roughly 350 kB of gzipped JavaScript is the point where Time to Interactive stops being comfortable. That total is the thing you are allocating.
Divide it in three parts. The host shell takes a fixed slice — routing, authentication, layout, and the shared runtime. Shared dependencies take a second slice, charged once because they are loaded once. What remains is divided across the remotes that appear on the heaviest route, not across every remote in the organisation.
// budgets.config.js — one file, read by every team's CI
module.exports = {
route: '/checkout',
totalGzipBudget: 350 * 1024,
entries: {
// The shell pays for routing, auth, layout and the federation runtime.
'host/shell': { gzip: 92 * 1024, owner: 'platform' },
// Charged once: every remote resolves against this copy.
'shared/vendor': { gzip: 138 * 1024, owner: 'platform' },
// Above-the-fold remotes on this route.
'remote/cart': { gzip: 54 * 1024, owner: 'payments' },
'remote/summary': { gzip: 38 * 1024, owner: 'payments' },
// Below-the-fold remotes are budgeted separately — see the lazy-loading page.
'remote/recommend': { gzip: 60 * 1024, owner: 'discovery', deferred: true },
},
};
Two details in that file matter more than the numbers. Every entry has an owner, so a failing build routes to a team rather than to a group chat. And deferred entries are tracked separately, because a remote that loads after first interaction should not compete for the same budget as one that blocks it.
Setup: measuring the composed page #
The measurement has to happen against a real composition. A remote measured alone is measuring a different application than the one users load.
// scripts/measure-route.mjs — run against a preview deployment, not localhost
import { launch } from 'chrome-launcher';
import lighthouse from 'lighthouse';
const chrome = await launch({ chromeFlags: ['--headless=new'] });
const { lhr } = await lighthouse(process.env.PREVIEW_URL + '/checkout', {
port: chrome.port,
// Mid-tier phone on 4G: the profile the budget was derived from.
formFactor: 'mobile',
screenEmulation: { mobile: true, width: 412, height: 823, deviceScaleFactor: 1.75 },
throttlingMethod: 'simulate',
});
await chrome.kill();
const metrics = {
lcp: lhr.audits['largest-contentful-paint'].numericValue,
tbt: lhr.audits['total-blocking-time'].numericValue,
tti: lhr.audits['interactive'].numericValue,
transferredJs: lhr.audits['network-requests'].details.items
.filter((r) => r.resourceType === 'Script')
.reduce((sum, r) => sum + r.transferSize, 0),
};
console.log(JSON.stringify(metrics, null, 2));
process.exitCode = metrics.tbt > 300 || metrics.transferredJs > 350 * 1024 ? 1 : 0;
Run this against a preview deployment where the host resolves real remotes, not mocks. If your preview environment pins remotes to their latest published versions, the measurement also catches the case where another team’s release is what pushed you over.
Integration: attributing bytes to a remote #
A total is not actionable. To route a failure to an owner you need per-remote attribution, which means mapping each network request back to the remote that requested it. Module Federation makes this tractable because every remote’s assets live under its own public path.
// attribute.mjs — group script transfer sizes by remote, using publicPath prefixes
const ORIGINS = {
'https://cdn.example.com/shell/': 'host/shell',
'https://cdn.example.com/cart/': 'remote/cart',
'https://cdn.example.com/summary/': 'remote/summary',
'https://cdn.example.com/vendor/': 'shared/vendor',
};
export function attribute(networkRequests) {
const totals = new Map();
for (const req of networkRequests) {
if (req.resourceType !== 'Script') continue;
const prefix = Object.keys(ORIGINS).find((p) => req.url.startsWith(p));
// An unmatched script is itself a finding: something is loading from
// an origin no team has claimed.
const key = prefix ? ORIGINS[prefix] : 'unattributed';
totals.set(key, (totals.get(key) ?? 0) + req.transferSize);
}
return Object.fromEntries(totals);
}
The unattributed bucket is worth keeping. In practice it catches third-party tags injected by a remote, analytics scripts nobody remembered adding, and remotes deployed to an origin that was never registered in the budget file.
Serving every remote from a distinct path prefix is therefore not only a publicPath concern — it is what makes per-team attribution possible at all.
Edge cases #
A shared dependency crosses the boundary. When the host and a remote both use a charting library and it is shared, the bytes appear under the shared vendor bundle even though only one remote uses it. Charge it to the remote that introduced it until a second consumer appears, then move it to the shared slice. Otherwise the platform team absorbs every team’s dependencies.
Version negotiation loads a second copy. If runtime negotiation fails to find a satisfying version, a remote silently falls back to its own bundled copy. That duplicate is real weight that no static analysis of either build will predict. Only a composed measurement catches it.
The heaviest route is not the homepage. Budgets are per route, and the route with the most remotes is usually a deep authenticated page nobody load-tests. Pick the route by counting remotes, not by traffic.
Preloading moves cost, it does not remove it. A preloaded remote entry improves perceived latency and can worsen contention. Budget the bytes regardless of when they are fetched, and track deferred entries separately rather than exempting them.
Testing and validation #
Wire three checks, at three different costs, so the expensive one runs rarely:
| Check | Runs on | Cost | Catches |
|---|---|---|---|
| Per-entry size limit | Every pull request | seconds | A remote growing its own bundle |
| Composed route measurement | Every merge to main | ~2 minutes | Duplication and cross-team drift |
| Field data review (RUM) | Weekly | ongoing | What synthetic testing missed |
The per-entry check belongs in each team’s own pipeline, so it fails in front of the person who caused it. The composed measurement belongs to the platform team’s pipeline, because it is the only one with a view of the whole page. Field data closes the loop: synthetic runs use one device profile, and real users do not.
Deployment #
Budgets need a release valve or they get disabled. Two mechanisms work in practice.
The first is an explicit, expiring waiver. A team that needs to ship over budget records it in the budget file with a date, and CI fails once the date passes. This keeps the number honest while acknowledging that some releases genuinely cannot wait.
'remote/recommend': {
gzip: 60 * 1024,
owner: 'discovery',
deferred: true,
// Expiring waiver: CI fails from this date unless the entry is back under budget.
waiver: { until: '2026-09-15', reason: 'model bundle pending split, see PLAT-2841' },
},
The second is budget renegotiation as a scheduled event rather than an emergency. When a new remote is added to a route, the total is re-divided deliberately, with the platform team arbitrating. Doing this on a schedule prevents the pattern where whichever team ships last is the one that has to cut.
Budgets and caching are the same conversation #
A budget describes what a user downloads, and caching decides how often they download it. Treating the two separately produces a page that passes every synthetic check on a cold load and still feels slow to returning users — or, more commonly, the reverse: a page that is fine for regulars and punishing for anyone arriving from search.
The interaction is specific to federation. Because each remote publishes independently, the shared vendor chunk is the only asset with a genuinely long cache life across releases. Every time a remote ships, its own entry and chunks change hash and are re-downloaded. If a remote deploys twelve times a week, its bytes are effectively uncached for a meaningful share of your users, while the shared slice is downloaded once a quarter.
That asymmetry should change how you allocate. A remote that deploys daily should get a tighter budget than one that deploys monthly, because the same number of bytes costs its users far more often. A useful way to express this is an effective weight: the entry size multiplied by how frequently a returning user has to re-fetch it.
| Entry | Gzipped | Deploys / week | Effective weekly cost |
|---|---|---|---|
| shared/vendor | 138 kB | 0.2 | 28 kB |
| host/shell | 92 kB | 1 | 92 kB |
| remote/cart | 54 kB | 12 | 648 kB |
| remote/summary | 38 kB | 2 | 76 kB |
Read that table and the cart remote, which looks like the third-largest entry, is by far the most expensive thing on the route. Moving stable code out of a high-cadence remote and into the shared slice is worth far more than shaving the same number of kilobytes off a remote that rarely changes. This is the same discipline described in CDN cache invalidation for federated remotes, viewed from the performance side rather than the correctness side.
Rolling budgets out to teams already over them #
Introducing budgets to a codebase that is already too heavy is where most of these programmes die. Announcing a number that everybody currently fails produces one of two outcomes: the number is ignored, or the check is disabled. Both end the same way.
The approach that survives contact with real teams is a ratchet. Instead of setting the target and failing everything, you record each entry’s current size as its budget and forbid it from growing. Nobody is broken on day one, every team’s build stays green, and the page stops getting worse immediately — which is most of the value.
// scripts/ratchet.mjs — record today's sizes as the ceiling, then only allow decreases
import { readFileSync, writeFileSync } from 'node:fs';
const budgets = JSON.parse(readFileSync('budgets.lock.json', 'utf8'));
const measured = JSON.parse(readFileSync('measured.json', 'utf8'));
let failed = false;
for (const [entry, size] of Object.entries(measured)) {
const ceiling = budgets[entry] ?? size; // new entries seed their own ceiling
if (size > ceiling) {
console.error(`${entry}: ${size} > ${ceiling} — this entry may not grow`);
failed = true;
} else if (size < ceiling) {
// A real reduction tightens the ceiling, so the win cannot be given back later.
budgets[entry] = size;
}
}
writeFileSync('budgets.lock.json', JSON.stringify(budgets, null, 2));
process.exitCode = failed ? 1 : 0;
The important line is the else if. When a team genuinely reduces an entry, the ceiling tightens to the new value, so the saving cannot be silently spent by the next feature. Over a few months the ratchet walks each entry down towards the target without a single confrontational conversation, and only then do you replace the lock file with the deliberate allocation described above.
Two things make the ratchet work in practice. New entries seed their own ceiling rather than failing, so onboarding a remote is never blocked by the budget system itself. And the lock file is committed, which means the history of every entry’s size is in version control — a far better conversation starter than a dashboard.
Team topology for budget ownership #
The budget file needs one owner and many contributors, which maps cleanly onto the platform-team model described in managing cross-team coupling.
The platform team owns the total, the shared slice, and the arbitration when a route needs re-dividing. It does not own individual remote budgets, because it cannot fix them. Each stream-aligned team owns its own entries, its own numbers, and the decision about how to spend them — a team that wants a richer interaction is free to buy it by removing something else within its own allowance.
What the platform team must provide in return is measurement that teams trust. If the composed-route check is flaky, slow, or measures an environment that does not resemble production, teams will correctly stop believing it, and the budgets become theatre. Investing in a stable preview environment is therefore not a nice-to-have — it is the precondition for the whole arrangement.
Common pitfalls #
| Pitfall | Root cause and resolution |
|---|---|
| Budget passes in CI, page is slow in production | Measuring a standalone harness. Measure a composed preview deployment instead. |
| Everyone is under budget, total is over | Shared slice absorbing every team’s dependencies. Charge new dependencies to the introducing remote. |
| Regression detected weeks later | Budget reported to a dashboard, not the build. Make the check exit non-zero. |
| Team disputes the number | No owner recorded in the budget file. Every entry needs exactly one owning team. |
| Bytes are fine, page still janky | Only size budgeted. Add a Total Blocking Time budget for main-thread cost. |
| Below-the-fold remote fails the budget | Deferred entries pooled with blocking ones. Track them in a separate allowance. |
FAQ #
Should each remote get an equal share of the budget?
No. Divide by what the remote does on that route, not by team headcount. A cart summary that renders four lines of text should not get the same allowance as a remote rendering an interactive table. Equal shares feel fair and produce a page where the important remote is starved while a trivial one has room it never uses.
What is a reasonable starting budget for a single remote?
For an above-the-fold remote on a mid-tier mobile profile, 40 to 60 kB gzipped is workable once shared dependencies are genuinely shared. If a remote cannot fit, the usual cause is a dependency that should be in the shared scope rather than in the remote’s own bundle — see managing shared dependencies at runtime.
Do budgets apply to remotes loaded after first interaction?
Yes, but as a separate allowance. Deferred remotes do not compete for Time to Interactive, so pooling them with blocking entries either penalises them unfairly or hides real weight. Track them under their own total and their own threshold.
How do we stop budgets from being quietly raised?
Put the budget file behind the platform team’s ownership in CODEOWNERS, and require the expiring-waiver mechanism for exceptions. Raising a number then becomes a reviewed decision with a name attached, rather than a one-line change in an unrelated pull request.
Does Module Federation itself cost enough to budget?
The runtime is small — roughly 10 to 15 kB gzipped — but the pattern it enables is not. Budget the runtime as part of the host shell and spend your attention on duplication, which is routinely ten times larger.
Should the budget cover CSS and images as well as JavaScript?
Track them, but budget them separately. JavaScript is the constrained resource on a federated page because it has to be parsed and executed on the main thread before the page responds; a 40 kB image costs bandwidth but not interactivity. Where remotes ship their own stylesheets — see handling CSS and asset loading in Vite remotes — a per-remote CSS budget is worth adding, because duplicated design-system styles accumulate the same way duplicated JavaScript does.
What do we do when a third-party script blows the budget?
Attribute it to the remote that introduced it and treat it like any other dependency. The unattributed bucket in the measurement script exists precisely to surface these, and a tag manager injected by one remote is not a platform problem — it is that remote’s bytes arriving by a different route.