Analyzing Federated Bundles with webpack-bundle-analyzer #
A treemap of a federated build looks like any other treemap and means something different — this page shows how to read it so a consumed shared module is not mistaken for a bundled copy, and how to see across remotes rather than one at a time.
The core difficulty is that the analyzer reports one build, while duplication is a property of several builds composed at runtime. A remote’s report can be flawless while the page it renders on carries three copies of React. It extends the measurement work in avoiding bundle duplication.
Prerequisites #
- Webpack 5.90+ with
webpack-bundle-analyzer^4.10.0. - A production build per remote — a development build’s sizes are meaningless for this.
- The
sharedconfiguration for each remote to hand, so the report can be read against intent. - For the cross-remote view, the ability to run all builds and collect their stats files.
Step 1 — Emit a stats file rather than opening the server #
The interactive server is convenient for a single look. A stats file is what lets you compare builds and script assertions.
// remote/webpack.prod.js
const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer');
module.exports = {
plugins: [
new BundleAnalyzerPlugin({
analyzerMode: 'static', // write a report, do not open a browser
reportFilename: '../reports/cart.html',
generateStatsFile: true,
statsFilename: '../reports/cart-stats.json',
openAnalyzer: false,
// Gzipped is what users pay; parsed is what the main thread pays.
defaultSizes: 'gzip',
}),
],
};
Keep both size measures in mind while reading. Gzipped size predicts download time; parsed size predicts main-thread cost, and a large but highly compressible module can be cheap to fetch and expensive to execute.
Step 2 — Learn to recognise a consumed shared module #
This is the reading that trips people up. When a dependency is genuinely shared, it does not disappear from the report — it appears as a small consume-shared-module stub plus, usually, a fallback chunk containing the real thing.
remoteEntry.js
├── webpack/container/entry/cart
├── consume-shared-module|default|react|^18.2.0|false ← a reference, not a copy
└── …
vendors-node_modules_react_index_js.chunk.js ← the fallback
└── node_modules/react/… ← only loaded if the scope is empty
Two rules make the distinction reliable. A path containing consume-shared-module is a reference: the runtime will ask the share scope for it and normally get the host’s copy. A path under node_modules/react/ inside the entry chunk is a real bundled copy and is a problem.
The fallback chunk is the subtlety. It contains a full copy of React and it is normal — it exists so the remote can run standalone. It is only downloaded when the share scope cannot satisfy the request. Counting it as duplication produces false alarms; ignoring it entirely misses the case where it is being loaded in production because negotiation is failing.
Step 3 — Check that shared modules left the entry #
The assertion worth automating is that nothing declared as shared appears as a real module in the entry chunk.
// scripts/assert-shared.mjs
import { readFileSync } from 'node:fs';
const stats = JSON.parse(readFileSync('reports/cart-stats.json', 'utf8'));
const SHARED = ['react', 'react-dom', 'react-router-dom', '@acme/ds'];
const entry = stats.chunks.find((c) => c.names?.includes('remoteEntry'));
const offenders = new Set();
for (const modId of entry.modules ?? []) {
const name = modId.name ?? '';
if (name.includes('consume-shared')) continue; // a reference: fine
const hit = SHARED.find((dep) => name.includes(`node_modules/${dep}/`));
if (hit) offenders.add(hit);
}
if (offenders.size) {
console.error(`::error::declared shared but bundled into the entry: ${[...offenders].join(', ')}`);
process.exit(1);
}
Scoping the check to the entry chunk is what makes it correct. Widening it to every chunk flags the legitimate standalone fallback and the check gets disabled within a week.
Step 4 — Compare across remotes, not within one #
Duplication lives between builds. Collect every remote’s stats file and look for the same package appearing as a real module in more than one.
// scripts/cross-remote-dupes.mjs
import { readFileSync, readdirSync } from 'node:fs';
const byPackage = new Map(); // package -> Set(remote)
for (const file of readdirSync('reports').filter((f) => f.endsWith('-stats.json'))) {
const remote = file.replace('-stats.json', '');
const stats = JSON.parse(readFileSync(`reports/${file}`, 'utf8'));
for (const mod of stats.modules ?? []) {
const m = mod.name?.match(/node_modules\/((?:@[^/]+\/)?[^/]+)\//);
if (!m || mod.name.includes('consume-shared')) continue;
if (!byPackage.has(m[1])) byPackage.set(m[1], new Set());
byPackage.get(m[1]).add(remote);
}
}
const shared = [...byPackage.entries()]
.filter(([, remotes]) => remotes.size > 1)
.sort((a, b) => b[1].size - a[1].size);
for (const [pkg, remotes] of shared.slice(0, 20)) {
console.log(`${pkg}: bundled by ${remotes.size} remotes (${[...remotes].join(', ')})`);
}
The output is a ranked list of sharing candidates, ordered by how many remotes are paying for the same package. It is the single most useful artefact for deciding what to add to the shared configuration next, and it cannot be produced from any one remote’s report.
Step 5 — Read the report against the shared configuration #
A number without intent is not a finding. Read each large module against what the configuration says should have happened.
A package that is large and declared shared should appear only as a stub and a fallback. If it is a real module in the entry, the share configuration is not taking effect — commonly because it is imported before the async bootstrap boundary, or because the specifier does not match the shared key.
A package that is large and not declared shared is a candidate. Check the cross-remote list from step 4: if several remotes bundle it, sharing it moves the cost from N copies to one.
A package that is large and used by one remote is simply that remote’s weight, and belongs in its budget rather than in the shared slice — the allocation reasoning in performance budgets for federated apps.
Step 6 — Confirm at runtime what the build predicted #
Static analysis predicts; only the running page proves. The share scope is the ground truth.
// Paste into the console on a composed page, after remotes have mounted.
Object.fromEntries(
Object.entries(__webpack_share_scopes__.default ?? {})
.map(([dep, entry]) => [dep, Object.keys(entry)]),
);
// { react: ['18.2.0'], 'react-dom': ['18.2.0'] } ← one version each: correct
More than one version listed for a dependency means the negotiation loaded two copies, regardless of what any individual build report said. This is the check to automate in the composed end-to-end suite, as in end-to-end testing federated apps with Playwright.
Step 7 — Keep the report from becoming a ritual #
An analyzer report generated on every build and read by nobody is pure cost. Turn the readings you actually act on into assertions and let the report be for investigation.
The two assertions worth automating are the ones in steps 3 and 6: nothing declared shared is bundled into an entry, and nothing resolves to more than one version at runtime. Both are a few lines, both fail with an actionable message, and between them they catch the duplication that matters.
Generate the visual report on demand — a manual workflow trigger, or automatically only when an assertion fails and someone needs to see where the weight is. That keeps build times down and means the report is opened when there is a reason to open it.
Verification #
- The stub is recognisable: search the report for
consume-shared-module. Every declared shared dependency should appear as one. - The entry is clean: the assertion in step 3 should pass on a correctly-configured remote and fail when you remove a
sharedentry. - The cross-remote list is plausible: the top entries should be packages you would expect several teams to use.
- Runtime agrees: the share scope should list exactly one version per shared dependency on a composed page.
- Sizes are production sizes: confirm the report was generated from a production build; development sizes can be several times larger.
Troubleshooting #
Symptom: React appears in the report despite being shared.
Diagnosis: it is the standalone fallback chunk, which is expected. Fix: confirm it is a separate chunk rather than part of the entry, and check at runtime whether it is actually being downloaded.
Symptom: the assertion passes but the page still has two copies.
Diagnosis: the duplicate comes from a different remote, or from a version mismatch at runtime. Fix: run the cross-remote comparison and the runtime share-scope check — a per-build assertion cannot see either.
Symptom: a shared dependency is bundled despite correct configuration.
Diagnosis: it is imported before the async bootstrap boundary, so webpack cannot defer it. Fix: move the import behind the import('./bootstrap') boundary described in step-by-step Webpack 5 container configuration.
Symptom: two packages that look identical are reported separately.
Diagnosis: a nested node_modules copy from a transitive dependency requiring a different range. Fix: deduplicate at the package-manager level first — no federation setting will merge two genuinely different versions.
Reading the same report for Vite remotes #
An estate with both Webpack and Vite remotes needs the same analysis from two different tools, and the reports are not directly comparable. rollup-plugin-visualizer produces the closest equivalent, and in JSON mode its output can be normalised into the same shape the cross-remote script expects.
Two differences matter when reading it. Rollup reports modules by their resolved file path without Webpack’s synthetic consume-shared-module entries, so the stub-versus-copy distinction from step 2 does not exist — a shared dependency under @originjs/vite-plugin-federation is simply absent from the graph rather than present as a reference. That makes the entry-chunk assertion simpler to write and slightly less informative, because absence does not tell you whether the dependency will actually resolve at runtime.
The second difference is that Vite’s chunking defaults differ enough that a naive size comparison between a Webpack remote and a Vite remote is misleading. Compare each remote against its own history rather than against remotes built by the other tool, and rely on the runtime share-scope check for the cross-tool question — it is the only measurement that means the same thing on both sides.