Measuring Bundle Size Impact of Shared Dependencies #
You need to know exactly how many kilobytes a shared library like React adds to your host and each remote — and whether it is actually deduplicated at runtime instead of silently shipping twice.
Without that number you are guessing. Over-share and you bloat the host’s initial chunk; under-share and heavy libraries duplicate across remotes. The fix is to measure each layer of the build with a different tool, then wire the result into a budget so regressions fail the build instead of reaching production.
This guide walks through three complementary analyzers — webpack-bundle-analyzer for webpack hosts, rollup-plugin-visualizer for Vite remotes, and source-map-explorer for any shipped artifact — and shows how to read each one for shared-dependency attribution.
Prerequisites #
- A working Module Federation setup. If you are still wiring one up, this builds on the patterns in Avoiding Bundle Duplication and the runtime sharing model in Managing Shared Dependencies at Runtime.
- Node.js 18+ and a package manager (
npm,pnpm, oryarn). - Source maps enabled for production builds (
devtool: 'source-map'in webpack,build.sourcemap: truein Vite). Without themsource-map-explorercannot attribute bytes. - Analyzer packages installed:
npm install --save-dev webpack-bundle-analyzer rollup-plugin-visualizer source-map-explorer
Step 1 — Confirm what each remote declares as shared #
Before measuring, make the shared scope explicit. Implicit defaults make attribution unreliable because a library can be half-shared.
// webpack.config.js (host and each remote use the same shared block)
const { ModuleFederationPlugin } = require('webpack').container;
module.exports = {
devtool: 'source-map',
plugins: [
new ModuleFederationPlugin({
name: 'host_app',
remotes: { remoteA: 'remoteA@http://localhost:3001/remoteEntry.js' },
shared: {
react: { singleton: true, requiredVersion: '^18.2.0', eager: false },
'react-dom': { singleton: true, requiredVersion: '^18.2.0', eager: false },
},
}),
],
};
singleton: true is what makes deduplication possible; if you skip it, every remote bundles its own copy. The mechanics of getting a single instance to load are covered in Configuring Shared Singletons to Deduplicate React.
Step 2 — Measure the webpack host with webpack-bundle-analyzer #
Add the analyzer plugin so the production build emits a static treemap report you can read in CI artifacts.
// webpack.analyze.js
const { merge } = require('webpack-merge');
const base = require('./webpack.config.js');
const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer');
module.exports = merge(base, {
mode: 'production',
plugins: [
new BundleAnalyzerPlugin({
analyzerMode: 'static',
reportFilename: 'bundle-report.html',
defaultSizes: 'gzip',
openAnalyzer: false,
generateStatsFile: true,
statsFilename: 'stats.json',
}),
],
});
Run it:
npx webpack --config webpack.analyze.js
Open dist/bundle-report.html. In the treemap, look for react and react-dom under a shared chunk (often named for the shared scope rather than an entry). Switch the size dropdown to Gzipped — raw “stat” sizes overstate real network cost. The gzip number for the React box is the kilobyte figure you report.
Two things are worth checking in the same view. First, confirm the shared libraries live in a chunk separate from your application code; if React bytes are interleaved with your own modules, the chunk cannot be cached independently and every host change re-downloads React. Second, scan for the same library appearing in more than one box — webpack-bundle-analyzer renders each physical copy, so two react boxes is direct visual proof of duplication that no aggregate number would reveal.
The emitted stats.json is also useful on its own: it is the canonical record of the module graph for that build, and diffing it between two commits is the most precise way to attribute a regression to a specific dependency bump.
Step 3 — Measure a Vite remote with rollup-plugin-visualizer #
Vite remotes go through Rollup, so use the visualizer to produce an equivalent treemap.
// vite.config.ts
import { defineConfig } from 'vite';
import { visualizer } from 'rollup-plugin-visualizer';
export default defineConfig({
build: { sourcemap: true },
plugins: [
visualizer({
filename: 'dist/stats.html',
template: 'treemap',
gzipSize: true,
brotliSize: true,
}),
],
});
npx vite build
Open dist/stats.html and enable the gzip metric in the top-right selector. Confirm the shared library appears in one chunk only. If react shows up inside multiple remote-specific chunks, the federation plugin is not externalizing it — the byte count you see is duplication, not legitimate code.
Step 4 — Verify the shipped payload with source-map-explorer #
Treemaps describe the build graph; source-map-explorer reads the shipped JS plus its source map, so it tells you what users actually download. This is the layer where silent duplication surfaces.
npx source-map-explorer "dist/**/*.js" --html dist/sme-report.html
For a quick machine-readable total per file, use JSON output:
npx source-map-explorer "dist/**/*.js" --json | \
node -e "const d=JSON.parse(require('fs').readFileSync(0));\
for (const r of d.results) {\
const react = Object.entries(r.files).filter(([f])=>/node_modules\/react(-dom)?\//.test(f));\
const bytes = react.reduce((a,[,b])=>a+b,0);\
if (bytes) console.log(r.bundleName, (bytes/1024).toFixed(1)+' KB react');\
}"
If React bytes appear under more than one bundle name, the same library is shipping twice. That is the exact regression measurement exists to catch.
Why does this layer matter when the treemaps already looked clean? Because a treemap reflects the build graph’s intent, while the shipped artifact plus its source map reflects what the runtime actually negotiated. A version mismatch, an eager flag set on only one side, or a remote built from a stale dependency lockfile can all produce a graph that looks deduplicated yet emits two physical copies. source-map-explorer is the only one of the three tools that reads the final bytes, so treat it as the source of truth and the treemaps as faster, less authoritative previews.
Step 5 — Turn the measurement into a build budget #
Once you trust the numbers, freeze them as a threshold so CI fails on regression instead of a human noticing later.
#!/usr/bin/env bash
# scripts/check-shared-budget.sh
set -euo pipefail
BUDGET_KB=150
TOTAL=$(npx source-map-explorer "dist/**/*.js" --json | \
node -e "const d=JSON.parse(require('fs').readFileSync(0));\
let total=0; for (const r of d.results){\
for (const [f,b] of Object.entries(r.files)){\
if (/node_modules\/(react|react-dom)\//.test(f)) total+=b;\
}}\
console.log(Math.round(total/1024));")
echo "Shared React payload: ${TOTAL} KB (budget ${BUDGET_KB} KB)"
if [ "$TOTAL" -gt "$BUDGET_KB" ]; then
echo "FAIL: shared dependency budget exceeded" >&2
exit 1
fi
# .github/workflows/bundle-budget.yml (excerpt)
- run: npx vite build # or: npx webpack --config webpack.analyze.js
- run: bash scripts/check-shared-budget.sh
Step 6 — Gate the shared chunk with size-limit #
The hand-rolled script above is precise but brittle: you maintain the byte math yourself. For a maintained gate that posts a PR comment and tracks history, add size-limit, which globs the emitted artifact and fails when a named entry crosses its threshold.
npm install --save-dev size-limit @size-limit/file
Point each limit at the chunk that holds the shared library. Because Module Federation emits the shared scope into its own file, you can budget React independently of your application code — a regression in one cannot mask a regression in the other.
{
"size-limit": [
{
"name": "shared react scope",
"path": "dist/assets/react-*.js",
"limit": "45 KB",
"gzip": true
},
{
"name": "host entry (excludes shared)",
"path": "dist/assets/index-*.js",
"limit": "70 KB",
"gzip": true
}
]
}
npx size-limit
size-limit defaults to the gzip number, so the threshold matches what the browser downloads — no stat-vs-gzip confusion. Run it in CI after the build; a non-zero exit fails the job, and the official andresz1/size-limit-action will diff the new size against the base branch and comment the delta on the pull request.
# .github/workflows/size-limit.yml (excerpt)
- uses: andresz1/size-limit-action@v1
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
build_script: build # runs `npm run build` before measuring
Keep the hand-rolled source-map-explorer check from Step 5 as well — it attributes bytes across every shipped file, catching duplication that a glob-on-one-chunk budget would miss. The two gates are complementary: size-limit watches the intended shared chunk for growth, while source-map-explorer watches for the library leaking into chunks it should never appear in.
Verification #
You have measured correctly when all four checks agree:
- Treemap (Step 2/3): React and React-DOM each appear in exactly one chunk, sized in gzip. Read the graph by area, not by name: a healthy federated build shows a single large
react-domrectangle sitting in its own top-level box, clearly separated from the boxes that hold your application modules. If you can trace two rectangles whose hover tooltips both resolve tonode_modules/react-dom/, that is two physical copies regardless of what the chunk names suggest — the graph is telling you the singleton did not take. - source-map-explorer (Step 4): the JSON pass reports React bytes under a single bundle name only.
- Shared scope at runtime: open the host in DevTools and inspect the live share scope —
__webpack_share_scopes__.default.react(webpack) or the equivalent__federation_shared__map — and confirm each shared key resolves to oneloadedentry, not one per remote. A single loaded instance here is the runtime’s own confirmation that deduplication happened. - Network tab: filter by JS, reload the host with all remotes mounted, and confirm a single
200/304fetch for the shared chunk. Multiple identical fetches mean the runtime fell back to per-remote copies.
When size-limit and the budget script exit 0 in CI, the share scope shows one loaded React, and the network tab shows one shared request, the figure on your report is real and enforced.
Troubleshooting #
The analyzer treemap shows two React boxes side by side.
The build graph contains two physical copies — the most common cause is a version skew that prevents the singleton from collapsing them, or a remote that omitted React from its shared block and bundled its own. Hover each box: if the paths are node_modules/react/ in the host chunk and node_modules/<remote>/node_modules/react/ (or a second top-level copy), one side is not consuming the shared scope. Add the missing entry to the offending side’s shared config and confirm both declare the same requiredVersion.
A vendor library is duplicated once per remote in the shipped output.
Every remote bundled the dependency locally because it was never declared shared, or it was shared without singleton: true. Non-singleton sharing lets the runtime keep multiple satisfying versions, so each remote that requested an incompatible range gets its own copy. For libraries that must be a single instance (React, the router, any stateful client), set singleton: true and align ranges; the full mechanics are in Configuring Shared Singletons to Deduplicate React and the version-resolution rules in Managing Shared Dependencies at Runtime.
The treemap shows React in one chunk, but the network tab shows it loaded twice.
The remote was built against a React version outside the host’s requiredVersion range, so the runtime rejected the singleton and loaded a fallback copy. Align versions across host and remotes; see the conflict-resolution patterns in Resolving Version Conflicts in Shared React Libraries.
Gzip and “stat” sizes differ by 3–4×, and budgets feel arbitrary.
You are reading raw stat sizes, which ignore compression. Always switch the analyzer to the gzip (or brotli) metric, because that is what the browser downloads. size-limit and rollup-plugin-visualizer’s gzipSize option report compressed sizes by default; webpack-bundle-analyzer needs defaultSizes: 'gzip'. Set your budget against the compressed number.
source-map-explorer reports “Unable to find a source map” or wildly wrong sizes.
Source maps are missing or not adjacent to the JS. Set devtool: 'source-map' (webpack) or build.sourcemap: true (Vite), rebuild, and ensure the .map files sit next to the .js files you pass in the glob. Inline base64 maps also work but slow the tool down. If a remote strips maps in production but the host keeps them, your cross-remote duplication check will silently skip the remote’s bytes — keep source maps on for the analyzed build even if you withhold them from the public deploy.