Module Federation Runtime Plugins and Hooks #
Retry logic, telemetry and fallback URLs written around every loadRemote() call end up inconsistent within a quarter — this page moves them into runtime plugins, where one implementation covers every remote the host loads.
The runtime plugin API in @module-federation/enhanced exposes the points where a remote is resolved, fetched, initialised and consumed. Anything cross-cutting belongs there rather than in call sites. It extends configuring Webpack Module Federation.
Prerequisites #
- @module-federation/enhanced
^0.6.0(the runtime plugin API is not in the built-inModuleFederationPlugin). - Webpack 5.90+ or a supported Vite setup.
- A manifest-driven host, so a plugin has somewhere to resolve alternatives from.
- Telemetry that accepts events tagged with a remote name and version.
Step 1 — Register a runtime plugin #
A runtime plugin is a factory returning an object of named hooks. Point the build at it and every remote load passes through it.
// host/webpack.config.js
const { ModuleFederationPlugin } = require('@module-federation/enhanced/webpack');
module.exports = {
plugins: [
new ModuleFederationPlugin({
name: 'shell',
remotes: { cart: 'cart@https://cdn.example.com/cart/remoteEntry.js' },
// Every path in this list runs for every remote.
runtimePlugins: [require.resolve('./src/mf-telemetry.js')],
shared: { react: { singleton: true } },
}),
],
};
// src/mf-telemetry.js
export default function telemetryPlugin() {
return {
name: 'mf-telemetry',
// Hooks are declared as functions; return a value to modify, or nothing to observe.
};
}
Keep each plugin single-purpose. Several small plugins compose predictably; one large plugin doing retries, telemetry and rewriting becomes impossible to reason about when one of them misbehaves.
Step 2 — Learn the hooks worth using #
Five hooks cover almost every real requirement.
beforeInit runs once before the runtime initialises and is where you adjust the remote list — swapping in an override, or filtering out a remote that should not load for this user.
beforeRequest runs before a specific remote is fetched, and can rewrite its entry URL. This is where a fallback origin or a version pin belongs.
afterResolve runs once the entry has been fetched and the container is available, which makes it the natural place to record load timing.
errorLoadRemote runs when a remote fails at any stage, and is the only hook that can substitute a replacement module — the difference between a blank region and a graceful fallback.
beforeLoadShare runs before a shared module is resolved from the scope, and is useful for logging what negotiation actually decided.
export default function observabilityPlugin() {
return {
name: 'mf-observability',
beforeRequest(args) {
performance.mark(`remote:${args.id}:request`);
return args; // must return the (possibly modified) args
},
afterResolve(args) {
performance.mark(`remote:${args.id}:resolved`);
performance.measure(`remote:${args.id}`, `remote:${args.id}:request`, `remote:${args.id}:resolved`);
return args;
},
};
}
Returning args is mandatory in the hooks that transform. A hook that falls off the end returns undefined and the runtime has nothing to continue with — a mistake that produces a confusing failure some distance from its cause.
Step 3 — Implement retry with backoff in one place #
Transient failures are common: a chunk rotated mid-deploy, a flaky connection, an edge that has not warmed. Retrying at the plugin level covers every remote.
// src/mf-retry.js
const attempts = new Map();
const MAX = 2;
export default function retryPlugin() {
return {
name: 'mf-retry',
async errorLoadRemote(args) {
const { id, error } = args;
const tried = attempts.get(id) ?? 0;
// Only retry transient failures. A missing export will fail identically forever.
const transient = /Loading (chunk|script) failed|NetworkError|timeout/i.test(String(error));
if (!transient || tried >= MAX) return; // returning nothing lets the error propagate
attempts.set(id, tried + 1);
await new Promise((r) => setTimeout(r, 2 ** tried * 300));
return args.origin.loadRemote(id); // retry through the runtime
},
};
}
The transient check is what keeps this from being harmful. Retrying a deterministic failure — a renamed export, a blocked origin — triples the console noise and the telemetry volume with no chance of success.
Step 4 — Fall back to an alternative origin #
When a remote’s primary CDN is unavailable, a plugin can rewrite the request rather than every call site handling it.
// src/mf-fallback.js
const MIRRORS = {
cart: ['https://cdn.example.com/cart/', 'https://cdn-backup.example.com/cart/'],
};
export default function fallbackPlugin() {
const failed = new Set();
return {
name: 'mf-fallback',
beforeRequest(args) {
const mirrors = MIRRORS[args.id];
if (!mirrors) return args;
// Skip any origin already known to be failing in this session.
const usable = mirrors.find((base) => !failed.has(base)) ?? mirrors[0];
args.options.remotes = args.options.remotes.map((r) =>
r.name === args.id ? { ...r, entry: usable + 'remoteEntry.js' } : r);
return args;
},
errorLoadRemote(args) {
const base = MIRRORS[args.id]?.find((b) => String(args.error).includes(b));
if (base) failed.add(base); // remember, so the retry picks a different mirror
return; // let retryPlugin handle the actual retry
},
};
}
Note the separation: this plugin decides where to load from and the retry plugin decides whether to try again. Composing two focused plugins is far easier to debug than one that does both.
Integrity hashes have to be per-mirror if the mirrors serve different builds. If they serve identical bytes — which is the sane arrangement — one hash covers both, as described in subresource integrity for remote entry files.
Step 5 — Substitute a placeholder instead of failing #
For a non-critical remote, errorLoadRemote can return a stand-in module so the page never sees an exception.
// src/mf-graceful.js
const OPTIONAL = new Set(['recommendations', 'chat', 'promo']);
export default function gracefulPlugin() {
return {
name: 'mf-graceful',
errorLoadRemote(args) {
if (!OPTIONAL.has(args.id)) return; // critical remotes must still fail loudly
reportEvent('remote-degraded', { remote: args.id, error: String(args.error) });
// A module shaped like the real one, rendering nothing.
return () => ({ default: () => null });
},
};
}
The allow-list is essential. Silently substituting a placeholder for a critical remote hides a real outage behind a page that looks slightly emptier than usual, and nobody will notice until a metric moves days later.
Step 6 — Observe what the share scope actually decided #
Version negotiation is the hardest part of federation to reason about, and beforeLoadShare makes it visible.
// src/mf-share-log.js — development and preview only
export default function shareLogPlugin() {
return {
name: 'mf-share-log',
beforeLoadShare(args) {
if (import.meta.env.MODE === 'production') return args;
const available = Object.keys(args.shareInfo?.scope?.[args.pkgName] ?? {});
console.log(`[mf] ${args.pkgName}: available ${available.join(', ')}`);
if (available.length > 1) {
console.warn(`[mf] ${args.pkgName} has ${available.length} versions in scope`);
}
return args;
},
};
}
The warning on more than one version is the useful part. It catches a duplicate at the moment it is introduced, in the console of whoever introduced it, rather than in a bundle report weeks later.
Step 7 — Keep plugins ordered and testable #
Plugins run in the order they are registered, and for hooks that transform arguments the order changes the result.
runtimePlugins: [
require.resolve('./src/mf-fallback.js'), // 1. decide where to load from
require.resolve('./src/mf-retry.js'), // 2. decide whether to try again
require.resolve('./src/mf-graceful.js'), // 3. last resort: substitute a placeholder
require.resolve('./src/mf-telemetry.js'), // 4. observe, never modify
],
Put observation-only plugins last so they see the final decisions rather than an intermediate state.
Because a plugin is a plain factory returning an object, it is directly unit-testable without any federation infrastructure:
import retryPlugin from '../src/mf-retry.js';
it('does not retry a deterministic failure', async () => {
const plugin = retryPlugin();
const loadRemote = vi.fn();
await plugin.errorLoadRemote({
id: 'cart', error: new Error('Module ./Missing does not exist'), origin: { loadRemote },
});
expect(loadRemote).not.toHaveBeenCalled();
});
Verification #
- The plugin runs: add a
console.logtobeforeRequestand confirm it fires once per remote. - Retry is bounded: block a remote’s origin and confirm exactly two retries with growing delays, then a clean failure.
- Deterministic failures are not retried: request a non-existent module and confirm it fails once.
- Fallback works: block the primary mirror and confirm the second one is used.
- Placeholders are scoped: confirm an optional remote degrades silently and a critical one still throws.
- Order is respected: log from each plugin and confirm the sequence matches the registration order.
Troubleshooting #
Symptom: the runtime plugin is never called.
Diagnosis: the build uses the built-in ModuleFederationPlugin rather than the one from @module-federation/enhanced. Fix: import from @module-federation/enhanced/webpack.
Symptom: remotes fail with an unhelpful error after adding a plugin.
Diagnosis: a transforming hook returned nothing. Fix: every hook that modifies must return the args object.
Symptom: retries loop indefinitely.
Diagnosis: the attempt counter is keyed by something that changes each try, so the limit never applies. Fix: key it on the stable remote id, as in step 3.
Symptom: an outage looked like a quiet day.
Diagnosis: the graceful plugin substituted placeholders for a critical remote. Fix: restrict it to an explicit allow-list of optional remotes.
What does not belong in a runtime plugin #
The API is broad enough that it invites misuse, and two categories are worth ruling out explicitly.
Business logic is the first. A plugin that decides which remote to load based on the user’s subscription tier, or that rewrites a module id according to an experiment, has moved a product decision into infrastructure that runs before the application boots. It becomes untestable in the ordinary way, invisible to anyone reading the feature’s code, and impossible to change without a host release — which is the coupling federation exists to remove. Route these decisions through the manifest or through the flag mechanism in progressive rollout with feature flags for remotes instead.
Security controls are the second. It is tempting to validate origins or check integrity hashes in beforeRequest, and it is the wrong layer: a plugin runs inside the page, after the bundle it would be protecting has already been trusted, and it can be bypassed by anything that loads a remote through another path. Origin allow-lists and manifest verification belong before the runtime starts, as described in validating remote manifests before loading.
The useful test is whether the behaviour should apply identically to every remote, now and in future. Retry, mirrors, telemetry and share-scope logging all pass it. Anything that needs a condition naming a specific remote for a product reason does not.