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.

Where cross-cutting federation logic belongs Retry, fallback and telemetry apply to every remote, so implementing them per call site guarantees divergence as the estate grows. Logic at every call site Retry written around each loadRemote Three teams, three retry policies Telemetry tags differ per call A new remote arrives with none of it Logic in a runtime plugin One implementation for every remote New remotes inherit it automatically Policies are unit-testable in isolation Call sites go back to plain imports
The last line of the second panel is the real win: a remote added next quarter is covered without anyone remembering to cover it.

Prerequisites #

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 #

The four hooks a remote load passes through Each stage of a remote load exposes a hook, and only the last one can substitute a replacement module when everything else has failed. Where each hook sits in the load beforeInit adjust the remote list beforeRequest rewrite the entry URL afterResolve container available errorLoadRemote retry or substitute
Hooks that transform must return their args object — falling off the end leaves the runtime with nothing to continue from.

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 #

How a plugin stack should respond to a failure Separate plugins decide where to load from, whether to retry, and whether the remote is optional, composing into one predictable policy. A remote failed to load. What should the plugin stack do? Transient network error Retry with backoff Chunk rotated or edge cold Bounded at two attempts Primary origin unreachable Try another mirror Remember the failed origin Retry picks the alternative Optional remote, all attempts failed Substitute a placeholder Allow-listed remotes only Report the degradation
Any remote outside the optional allow-list falls through all three branches and fails loudly, which is the intended behaviour.

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 #

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.