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.

What a federated stack trace is worth with and without maps In a federated system an unsymbolicated trace does not even identify the owning team, which is what turns a small bug into a long incident. No usable maps Trace points at main.a91f3c.js:1:48211 No indication which team owns it Incident starts with a search, not a fix Every remote has the same problem Maps uploaded and release-tagged Trace names the original function and file Error carries remote name and release Routes to one team immediately No source published publicly
The attribution matters as much as the line number: knowing which remote failed is most of the triage.

Prerequisites #

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 #

Four places source maps can live The choice trades source exposure against local debugging convenience, and only the last row is unambiguously wrong. Strategy Source exposed? DevTools works? Published beside the bundle yes, to anyone yes, immediately Uploaded to error reporting no no — traces resolve server-side Served from an authenticated path no, to the public yes, for signed-in engineers Not produced at all no no, and nothing symbolicates
Pick per remote and record the decision — the common failure is a remote that publishes maps because nobody configured it not to.

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 #

How a release identifier links a trace to its maps One identifier stamped into the build ties the uploaded maps, the reported error and the owning remote together without any manual correlation. Remote CI Error service CDN Incident 1. upload maps for [email protected] 2. publish bundle 3. error tagged release [email protected] 4. match release to maps 5. symbolicated frame + owner
Upload the maps before publishing the bundle: a map with no bundle is harmless, the reverse is an unreadable incident.

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 #

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.