Debugging Remote Entry Loading Failures

The federated region renders nothing, and the console gives you almost no material to work with:

Uncaught (in promise) ScriptExternalLoadError: Loading script failed.
(missing: https://reports.example.com/remoteEntry.js)
    at loadScript (webpack/container/reference/reports:14:9)

No status code, no reason. Browsers withhold error detail from script elements loading cross-origin resources, so the runtime genuinely cannot tell you whether the file was missing, blocked, or malformed.

That opacity is the whole difficulty. The fix is to stop reading the error and start walking the five layers a remote entry has to pass through, each of which has a decisive one-minute check.

Root cause: five layers, one indistinguishable error

Loading a remote is not one operation. The host resolves a URL, fetches a script, evaluates it, reads a container global, and then asks that container for a module — and the shared-scope negotiation described in module federation and cross-application chunk sharing happens inside the last step. A failure at any layer surfaces as the same unhelpful message.

Five Layers of a Remote Entry Load URL resolution, network fetch, cross-origin policy, script evaluation, and container access — each layer fails with the same error message but has a distinct check. 1. URL resolution wrong environment baked in 2. Network fetch 404 · 403 · wrong MIME type 3. Cross-origin policy missing allow-origin header 4. Script evaluation format mismatch, syntax target 5. Container access name mismatch, global undefined All five report: ScriptExternalLoadError the message never tells you which layer failed — the checks do

Layer-by-layer checks

Layer 1 — the URL. Copy the URL out of the error and compare it against the remote’s actual deployed location. A URL that is correct in staging and wrong in production means the remote’s origin was baked in at build time. It should be resolved at runtime instead:

// remote-config.js — resolve remote origins from runtime configuration
// so promoting a build between environments needs no rebuild.
const config = JSON.parse(document.getElementById('app-config').textContent);
export const remoteUrl = (name) => `${config.remotes[name]}/remoteEntry.js`;

Layer 2 — the fetch. Request the URL directly in a new tab and read the response headers. A 404 means the remote’s deploy did not publish the entry at the expected path; a 403 means an origin access rule; an HTML response body means a single-page-application catch-all is serving index.html for an unmatched path, which produces a syntax error on evaluation rather than a load error.

Layer 3 — cross-origin policy. A cross-origin script needs the appropriate access-control header when the host requests it with credentials or with a crossorigin attribute:

// webpack.config.js — Webpack 5, the REMOTE build
module.exports = {
  output: {
    // Let the remote's runtime derive its own base URL from its script's
    // location, rather than resolving chunk paths against the host document.
    publicPath: 'auto',
    // If the host sets crossorigin on injected scripts, the remote must match.
    crossOriginLoading: 'anonymous',
  },
};

publicPath: 'auto' fixes the most common second-order failure: the remote entry loads, but every chunk it then requests 404s against the host’s origin, because a relative public path resolves against the document, not the script.

Layer 4 — evaluation. If the response is JavaScript and still fails, check the output format and syntax target. A remote built as an ES module cannot be loaded by a host expecting a classic script global, and a remote targeting newer syntax than the host’s browser support matrix throws on parse.

Layer 5 — the container. After the script evaluates, the container global must exist under exactly the name the remote declared:

// paste in the console after the page loads
// The global name is the `name` field from the remote's federation config.
console.log(typeof window.reports, window.reports && Object.keys(window.reports));
// → "object" ["get", "init"]   ← healthy container
// → "undefined"                ← name mismatch between host and remote config

A mismatch here is nearly always a typo: the host references reports while the remote declares reportsApp. Nothing validates the pairing at build time, because the two builds never see each other.

Making failure survivable

Whatever the cause, a host should not blank because another team’s origin is unreachable. Wrap the remote load so the region degrades locally, following the boundary pattern in handling lazy chunk load failures and fallbacks:

What a Degraded Host Looks Like With the remote unavailable, navigation, layout and every local region still render; only the federated panel shows its fallback. host page — remote origin unreachable navigation — works local content — works forms, tables and filters interactive federated region — fallback error reported, same reserved box One region degrades; the session continues
// load-remote.js — a remote that fails should cost one region, not the page
export async function loadRemote(importer, name) {
  try {
    return await importer();
  } catch (error) {
    report('remote_load_failed', { name, message: String(error && error.message) });
    // A null module lets the caller render its degraded state deliberately,
    // instead of propagating an error the host has no way to interpret.
    return null;
  }
}
Why publicPath Decides Where the Chunks Come From With auto public path, the remote's chunks are requested from the remote origin. With a relative public path, the same requests go to the host origin and return 404. publicPath: 'auto' — correct host requests remoteEntry.js reports.example.com — 200 chunks from reports.example.com publicPath: '/' — chunks 404 host requests remoteEntry.js reports.example.com — 200 chunks from shell.example.com — 404 The entry always loads; only the follow-up requests reveal the misconfiguration

Step-by-step verification

  1. Fetch the entry directly. Confirm a 200 with a JavaScript content type, not HTML, and not a redirect chain.

  2. Confirm the cross-origin header. The response must permit the host origin. A wildcard is acceptable for a public remote; a specific origin is required when credentials are involved.

  3. Confirm the container global. After load, the remote’s declared name must exist on the global object with get and init methods.

  4. Confirm chunk origin. Trigger the federated region and check that its chunk requests go to the remote’s origin, not the host’s.

  5. Confirm cache headers. The entry should carry a short max-age with revalidation; its chunks should be immutable. An immutable entry means a redeployed remote is invisible to returning users.

  6. Confirm degraded behaviour. Block the remote’s origin and reload. The host must render fully with only the federated region replaced by its fallback.

Edge cases and gotchas

Content-Security-Policy. A host with a restrictive script-src blocks the remote’s origin before any request is made, and the console message is a CSP violation rather than a load error. Every federated origin must be in the policy.

Mixed protocol. A host on HTTPS cannot load a remote entry over HTTP; the request is blocked as mixed content. This surfaces mostly in local development against a staging remote.

Single-page-application catch-all routing. A remote host configured to serve index.html for unknown paths returns HTML with a 200 status for a mistyped entry path. The failure then appears as a syntax error mentioning an unexpected < character.

Stale entry after a remote deploy. If the entry is cached aggressively, the host keeps requesting chunk filenames from a previous release — the same stale-manifest failure described in fixing ChunkLoadError after a new deploy, one level up.

FAQ

Why does the remote entry load but its chunks 404 against the host origin?

Because the remote’s public path is relative, so its runtime builds chunk URLs against whatever origin the document was served from — the host’s. The remote entry itself loads fine because the host requested it by absolute URL, but every chunk it subsequently requests is resolved relative to the host. Setting the remote’s public path to auto lets its runtime derive the correct base from its own script URL.

What does ScriptExternalLoadError actually mean?

It means the host injected a script element for the remote entry and that element fired an error event. The browser deliberately withholds the reason from script error events for cross-origin resources, so the message carries no status code. The cause is almost always one of four things: a 404, a blocked cross-origin request, a wrong content type, or a network failure. Requesting the same URL directly in the network panel distinguishes them in seconds.

Should the remote entry file be cached?

Briefly, and never immutably. The remote entry is a manifest: it points at the remote’s current hashed chunks, and it must be re-fetched after the remote deploys or the host will keep asking for files that no longer exist. A short max-age with revalidation is the right shape. The chunks it points at are content-hashed and should be cached immutably, exactly as any other build output.