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.
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 configA 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:
// 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;
}
}Step-by-step verification
-
Fetch the entry directly. Confirm a 200 with a JavaScript content type, not HTML, and not a redirect chain.
-
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.
-
Confirm the container global. After load, the remote’s declared name must exist on the global object with
getandinitmethods. -
Confirm chunk origin. Trigger the federated region and check that its chunk requests go to the remote’s origin, not the host’s.
-
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.
-
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.
Related
- Module Federation and Cross-Application Chunk Sharing — the parent guide covering the container protocol
- Fixing Shared Dependency Version Conflicts in Module Federation — the failure that happens after the container loads successfully
- Handling Lazy Chunk Load Failures and Fallbacks — the degradation pattern every federated region needs
- Fixing ChunkLoadError After a New Deploy — stale manifests, one level down