Debugging Module Federation with Source Maps #
A federated stack trace usually lands in a minified chunk from a repository you have not checked out — this page makes those traces resolve to real source, for the right people, without exposing your code publicly.
Three problems compound. Each remote emits its own maps and publishes them independently, so a trace spans several map files at several origins. The maps are large and often not published at all. And when they are published, they are frequently world-readable, which is a different problem. It completes the debugging workflow described in testing and local development workflows.
Prerequisites #
- Webpack 5 or Vite 5 producing production builds per remote.
- Somewhere to store maps that is not the public CDN path serving the bundles.
- An error-reporting service that accepts uploaded source maps, if you want server-side symbolication.
- Each remote’s build stamping a release identifier that the maps are keyed by.
Step 1 — Emit maps that are usable but not attached #
devtool: 'source-map' produces a separate .map file and a sourceMappingURL comment pointing at it. That comment is what makes a browser fetch the map, and it is the thing to control.
// remote/webpack.prod.js
module.exports = {
mode: 'production',
// Full, accurate maps. Do NOT use eval-based devtools in production.
devtool: 'source-map',
output: {
filename: '[name].[contenthash].js',
// Point maps at a path the CDN does not serve publicly.
sourceMapFilename: '[file].map',
},
};
Avoid eval-source-map and cheap-module-source-map in production builds. The first inlines code in a form that breaks under a Content Security Policy without unsafe-eval; the second discards column information, which is exactly what you need when a minified line contains forty statements.
Step 2 — Decide where the maps live #
Three options, with genuinely different trade-offs.
Publishing maps beside the bundles is simplest and makes everyone’s DevTools work immediately, at the cost of publishing your source to anyone who looks. For an internal application behind authentication that is often an acceptable trade; for a public product it usually is not.
Uploading maps to the error-reporting service and not publishing them at all is the common production answer. Traces are symbolicated server-side, DevTools shows minified code, and no source leaves your infrastructure.
Serving maps from an authenticated path gives both, at the cost of some setup: the sourceMappingURL points at a location that requires a session your engineers have and the public does not.
# maps served only to authenticated internal users
location ~ \.map$ {
auth_request /internal-auth;
root /srv/remotes;
add_header Cache-Control "private, max-age=300";
}
Whichever you choose, choose it deliberately per remote and record it. The failure mode is a remote that publishes maps because nobody configured it not to.
Step 3 — Strip the sourceMappingURL when maps are private #
If maps are uploaded rather than served, the comment pointing at a non-existent file produces a 404 on every error and clutters the network panel.
// remote/webpack.prod.js
const { SourceMapDevToolPlugin } = require('webpack');
module.exports = {
devtool: false, // take manual control
plugins: [
new SourceMapDevToolPlugin({
filename: '[file].map',
// No comment appended: the map exists on disk for upload, not for the browser.
append: false,
}),
],
};
The build still writes the map, so the upload step has something to send. The browser simply never learns it exists.
Step 4 — Stamp a release identifier the maps are keyed by #
Symbolication needs to know which build a trace came from. Without it, a service holding maps for twenty releases cannot pick the right one.
// remote/webpack.prod.js
const { DefinePlugin } = require('webpack');
module.exports = {
plugins: [
new DefinePlugin({
// Same value used for the map upload and for error tagging.
'process.env.RELEASE': JSON.stringify(`cart@${process.env.VERSION}`),
}),
],
};
// remote/src/error-tag.js — every error carries its release
export function tagError(error) {
error.remote = 'cart';
error.release = process.env.RELEASE;
return error;
}
This is the same tagging the error boundary telemetry already applies, reused for a second purpose. Keeping one identifier for both means a trace and its map cannot disagree.
Step 5 — Upload maps in the same job that publishes the bundle #
Maps and bundles must be published together, or a trace from a new release arrives before its map exists.
# remote pipeline
- name: Build
run: npm run build
env: { VERSION: "${{ github.ref_name }}" }
- name: Upload source maps
run: |
npx sentry-cli releases new "cart@$VERSION"
npx sentry-cli releases files "cart@$VERSION" upload-sourcemaps dist \
--url-prefix "~/cart/" --rewrite
npx sentry-cli releases finalize "cart@$VERSION"
- name: Publish bundle
run: npx wrangler pages deploy dist --branch main
Uploading before publishing is the safe ordering: a map with no bundle is harmless, a bundle with no map is an unreadable incident.
--url-prefix must match the path the browser actually requests. This is the setting that most commonly makes symbolication silently fail — the maps are uploaded, the release is correct, and nothing resolves because the prefix is ~/ instead of ~/cart/.
Step 6 — Make DevTools work for a locally-running remote #
When investigating a remote you have checked out, the override workflow from running remotes locally against a deployed host gives you real source with breakpoints, because the dev build serves its own maps.
For a remote you have not checked out, DevTools workspaces can map the deployed bundle to a local directory:
// Served at /.well-known/appspecific/com.chrome.devtools.json from the remote's dev server
{
"workspace": {
"root": "/Users/you/src/cart-remote",
"uuid": "3f2b8c1e-9a4d-4f2a-b7c6-1e5d0a9c8b47"
}
}
In practice the override route is faster and less fragile for most investigations. Reach for workspaces when you need to read another team’s code while it is running inside a real page and cannot easily run it yourself.
Step 7 — Verify symbolication before you need it #
Symbolication fails silently and is discovered during an incident, which is the worst possible time. Test it deliberately on every release.
// remote/src/debug-throw.js — reachable only in preview builds
if (import.meta.env.MODE !== 'production' && location.search.includes('__throw')) {
// A named function so the symbolicated frame is recognisable.
(function deliberateRemoteFailure() {
throw new Error('source map verification');
})();
}
- name: Verify symbolication
run: |
npx playwright test e2e/symbolication.spec.js
node scripts/assert-symbolicated.mjs --release "cart@$VERSION" \
--expect-frame "deliberateRemoteFailure"
The assertion is that the reported trace contains the original function name and a real file path, not a minified identifier. That is a single check and it converts a class of incident-time surprises into a build failure.
Verification #
- Maps resolve or are absent by design: open a remote’s chunk in DevTools. You should see either original source or minified code with no failed
.maprequest. - No public maps:
curl -sI https://cdn.example.com/cart/main.<hash>.js.mapshould return 403 or 404 if maps are meant to be private. - Traces are symbolicated: trigger the deliberate error in preview and confirm the reported trace names the original function and file.
- Release tags match: the
releaseon a reported error must equal the release the maps were uploaded under. - Every remote is covered: trigger an error in each remote and confirm all of them symbolicate, not just the ones someone remembered to configure.
Troubleshooting #
Symptom: maps are uploaded but traces stay minified.
Diagnosis: the --url-prefix does not match the path the browser requested. Fix: compare the URL in a real trace against the prefix, and remember the remote’s public path segment is part of it.
Symptom: DevTools reports a 404 for every .map file.
Diagnosis: maps are uploaded rather than served, but the sourceMappingURL comment is still appended. Fix: use SourceMapDevToolPlugin with append: false, as in step 3.
Symptom: symbolication works for the host and not for remotes.
Diagnosis: only the host’s pipeline uploads maps. Fix: add the upload step to every remote’s pipeline; each remote owns its own maps because each publishes independently.
Symptom: line numbers resolve but columns are wrong.
Diagnosis: a cheap- devtool variant discarded column information. Fix: use full source-map in production builds, and accept the larger build artefact.
Symptom: traces stopped resolving after a rollback.
Diagnosis: the rolled-back release’s maps were pruned by a retention policy. Fix: keep maps for at least as long as you might roll back to a release — the same reasoning that forbids purging fingerprinted assets.
Retention, cost and the rollback window #
Source maps are large — commonly two to four times the size of the bundle they describe — and a federated estate produces one set per remote per release. A fleet of eight remotes deploying a few times a day generates a surprising volume within a month, and the natural response is a short retention policy.
Be careful how short. The binding constraint is not how long you want to investigate an error; it is how far back you might roll back. If a remote can be reverted to any release published in the last ninety days, then maps for those releases must still exist, or the first thing a rollback does is make the reverted code unreadable. Aligning map retention with the CDN’s asset retention removes the whole class of problem, and it is a one-line policy decision rather than an ongoing judgement call.
The other cost worth naming is build time. Full source-map output adds meaningfully to a production build, and on a large remote it can dominate. That is a real trade-off, but it is one to make once and deliberately rather than by quietly switching to a cheaper devtool the week someone complains about CI duration — the cheaper variants are exactly the ones that discard the information you will want.