Versioning Strategies for Remote Apps #

The promise of micro-frontends is independent deployment: each team ships its remote on its own cadence without coordinating a monolithic release. The moment you take that promise seriously, versioning stops being a packaging detail and becomes the contract that holds the whole system together.

When versioning is sloppy, the failure modes are nasty and intermittent. A remote ships a new build that expects React 18.3, the host pins 18.2, and strictVersion throws a fatal error mid-render. Or worse — strictVersion is off, two React copies load, hooks silently break, and you get a blank panel that reproduces only for users whose browser cached yesterday’s remoteEntry.js.

This guide covers the concrete mechanics of versioning remotes in a Module Federation setup: how to encode versions at build time, how to resolve them at runtime through a manifest, how to handle the edge cases that crash hosts, and how to roll a bad remote back in seconds without redeploying anything.

It sits under Core Micro-Frontend Architecture Tradeoffs, and goes deep on two topics with their own walkthroughs: Semantic Versioning for Module Federation Remotes and Backward-Compatible Remote API Contracts.

What breaks without a versioning strategy #

A remote in Module Federation is two things at once: a unit of deployment (a remoteEntry.js file at a URL) and a unit of contract (the modules it exposes plus the shared dependencies it negotiates). Versioning has to cover both, and the failures look different depending on which side drifts.

The strategy below treats all three as one problem: keep the host’s knowledge of any specific remote version out of the host build, and route every decision through a single source of truth that can change without a rebuild.

What Module Federation 2.0 changes #

If you are on the Module Federation 2.0 runtime (@module-federation/enhanced for webpack, @module-federation/vite for Vite), most of the manual plumbing in this guide is now first-class. The runtime ships a manifest format (mf-manifest.json) emitted automatically by the build, a @module-federation/runtime package that resolves and loads remotes programmatically, and lifecycle hooks (beforeRequest, errorLoadRemote, resolveShare) where you inject version logic.

The mental model is unchanged from MF 1.0: a host advertises ranges, the runtime negotiates a winner in the share scope, and you decide how strict that negotiation is. What 2.0 gives you is a supported place to hang the resolver, fallback, and telemetry hooks that earlier setups bolted on by hand. The hand-rolled resolver below still works on 1.0 and is worth reading even on 2.0 because it shows exactly what the hooks do for you.

Objectives #

Version negotiation through manifest indirection The host requests a remote by name and range, the manifest maps that to an immutable versioned URL, and the host loads and initializes the matching remote entry. Host shell "remoteUI" @ ^2.x no hardcoded URL Version manifest (CDN, no-cache) latest → 2.4.1 prevStable → 2.3.0 2.4.1 → /v2.4.1/… Remote entry /v2.4.1/ immutable, hashed resolve load init(shareScope) → factory() → mount
The manifest is the only thing that knows which version is live; the host knows a name and a range, the remote ships immutable versioned files.

Setup and config #

Build-time configuration decides two things: which dependencies are shared (and how strictly), and whether the host hardcodes remote URLs. Get the sharing rules right and keep the URLs out of the build.

// webpack.config.js (host application)
const { ModuleFederationPlugin } = require('webpack').container;

module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: 'host_app',
      // Remotes are declared by name only. The promise-based remote defers
      // URL resolution to runtime so the host build never pins a version.
      remotes: {
        remoteUI: `promise import('./bootstrap/resolveRemoteUI.js')`,
      },
      shared: {
        // Frameworks MUST be singletons with strict enforcement: a mismatch
        // should throw loudly, never silently load a second React.
        react: { singleton: true, strictVersion: true, requiredVersion: '^18.2.0' },
        'react-dom': { singleton: true, strictVersion: true, requiredVersion: '^18.2.0' },
        // Internal libs can tolerate minor drift — relax strictness so a remote
        // on @shared/ui 1.3 and a host on 1.2 still coexist.
        '@shared/ui': { singleton: false, strictVersion: false, requiredVersion: '^1.0.0' },
      },
    }),
  ],
};

The singleton/strictVersion/requiredVersion triad is the heart of dependency versioning. singleton: true forces a single shared copy in the runtime share scope; strictVersion: true turns an out-of-range version into a fatal throw instead of a silent second instance; requiredVersion is the range each consumer asserts. The deeper mechanics of how the share scope picks a winner are covered in Managing Shared Dependencies at Runtime.

Reserve strictVersion: true for stateful frameworks where two instances cause real corruption (React, the router, any state library). For stateless utilities, strict enforcement just turns harmless drift into hard outages — relax it.

How runtime negotiation actually resolves a version #

It helps to know precisely what the share scope does at init time, because the flags only make sense against the algorithm. When the first container initializes, it registers each shared module into __webpack_share_scopes__.default keyed by package name and exact version. When a second container initializes and asks for the same package, the runtime evaluates each consumer’s requiredVersion against the versions already registered and decides whether to reuse, add, or reject:

The winner among multiple satisfying versions is the highest registered version that satisfies all strict consumers. This is why a too-loose range is as dangerous as a too-strict one: requiredVersion: '*' accepts a major bump it was never tested against, while requiredVersion: '18.2.0' (no caret) rejects the patch release a sibling remote already loaded and throws under strictVersion.

A useful rule of thumb: pin the range loosely (^18.2.0), pin the resolution policy strictly (singleton + strictVersion for frameworks), and let the manifest — not the build — decide which remote version those frameworks have to coexist with.

Emit a version manifest at build time #

Each remote build writes a small descriptor of what it shipped. CI uses these to assemble the central manifest the host reads at runtime.

{
  "name": "remoteUI",
  "version": "2.4.1",
  "entry": "https://cdn.example.com/remoteUI/v2.4.1/remoteEntry.js",
  "exposes": ["./App", "./CheckoutWidget"],
  "shared": { "react": "^18.2.0", "react-dom": "^18.2.0" }
}

Treat this file as the build’s public contract. The matching SemVer rules for when version bumps major, minor, or patch are spelled out in Semantic Versioning for Module Federation Remotes.

Integration #

The host should never know a remote’s URL at build time. Resolve it at runtime against the manifest so a remote can ship — or roll back — without the host changing.

// bootstrap/resolveRemoteUI.js
// Resolves remoteUI's live URL from the manifest, then loads & inits the container.
const MANIFEST_URL = '/cdn/version-manifest.json';

async function resolveRemoteUI() {
  const manifest = await fetch(MANIFEST_URL, { cache: 'no-store' }).then((r) => r.json());
  const remote = manifest.remoteUI;
  if (!remote) throw new Error('No manifest entry for remoteUI');

  // The manifest maps "latest" → an immutable, content-addressed URL.
  const url = remote[remote.latest]?.entry;
  if (!url) throw new Error(`No entry URL for remoteUI@${remote.latest}`);

  await loadContainer('remoteUI', url);

  // Return the federation container interface webpack expects from a remote.
  return {
    get: (request) => window.remoteUI.get(request),
    init: (arg) => {
      try {
        return window.remoteUI.init(arg);
      } catch (e) {
        // Already initialized — safe to ignore on repeated dynamic imports.
      }
    },
  };
}

function loadContainer(globalName, url) {
  return new Promise((resolve, reject) => {
    if (window[globalName]) return resolve();
    const script = document.createElement('script');
    script.src = url;
    script.onload = () => resolve();
    script.onerror = () => reject(new Error(`Failed to load ${globalName} from ${url}`));
    document.head.appendChild(script);
  });
}

export default resolveRemoteUI();

Because the manifest is fetched with cache: 'no-store, a pointer change propagates on the next navigation. The remote chunks behind that pointer stay immutable and far-future cached, so swapping latest never invalidates anything but the small JSON file.

Selecting a version range, not just “latest” #

A more robust host passes the range it was built against and lets the resolver pick the newest compatible build, falling back to the previous stable when nothing matches.

// bootstrap/pickVersion.js
import semverSatisfies from 'semver/functions/satisfies';
import semverRcompare from 'semver/functions/rcompare';

export function pickVersion(remoteEntry, range) {
  const candidates = Object.keys(remoteEntry.versions)
    .filter((v) => semverSatisfies(v, range))
    .sort(semverRcompare);

  // Newest in-range build, else the last known-good stable.
  return candidates[0] ?? remoteEntry.prevStable;
}

This keeps the negotiation honest: the host advertises ^2.x, the manifest offers whatever is live, and the resolver picks the best match instead of blindly trusting a moving latest. Coordinating those ranges across teams is the heart of Managing Cross-Team Coupling.

The same wiring on the Module Federation 2.0 runtime #

On MF 2.0 you do not inject a script tag yourself — you register remotes with @module-federation/runtime and hang resolution and fallback on its hooks. The version manifest stays exactly the same; only the loading code collapses into a plugin.

// bootstrap/registerRemotes.ts — MF 2.0 runtime with manifest indirection
import { init, loadRemote } from '@module-federation/runtime';
import semverSatisfies from 'semver/functions/satisfies';
import semverRcompare from 'semver/functions/rcompare';

type RemoteEntry = { latest: string; prevStable: string; versions: Record<string, { entry: string }> };

function pickVersion(entry: RemoteEntry, range: string): string {
  const match = Object.keys(entry.versions)
    .filter((v) => semverSatisfies(v, range))
    .sort(semverRcompare)[0];
  return match ?? entry.prevStable;
}

export async function registerRemotes(range = '^2.x') {
  const manifest: Record<string, RemoteEntry> =
    await fetch('/cdn/version-manifest.json', { cache: 'no-store' }).then((r) => r.json());

  const version = pickVersion(manifest.remoteUI, range);

  init({
    name: 'host_app',
    remotes: [
      // Point at the resolved, immutable mf-manifest.json for that exact version.
      { name: 'remoteUI', entry: manifest.remoteUI.versions[version].entry },
    ],
    shared: {
      react: { version: '18.2.0', shareConfig: { singleton: true, requiredVersion: '^18.2.0' } },
    },
    plugins: [
      {
        name: 'version-fallback',
        // Runs when a remote entry 404s or a strictVersion check throws.
        errorLoadRemote({ id, error }) {
          console.warn(`remote ${id} failed (${error}); falling back to prevStable`);
          const fallback = manifest.remoteUI.versions[manifest.remoteUI.prevStable].entry;
          return loadRemote(fallback);
        },
      },
    ],
  });
}

The errorLoadRemote hook is the supported replacement for the manual try/catch fallback chain — it fires on a missing entry and on a strictVersion rejection, which is exactly the seam where versioning fails in production.

A breaking-change policy you can enforce #

Ranges and manifests are mechanism; a policy is what stops a remote team from shipping a major as a patch. Write the policy down and have CI enforce it, because the cost of a silent breaking change is paid entirely by consumers who never saw it coming.

A workable policy for an exposed remote:

The non-obvious clause is the last one: bumping a shared dependency’s required range is a breaking change to the remote even if its own API is untouched, because it changes who can host it. Treat a framework-range bump exactly like an API break — major version, parallel entry, migration window. The rules for deciding which bump applies are detailed in Semantic Versioning for Module Federation Remotes, and the contract tests that catch a misclassified bump live in Backward-Compatible Remote API Contracts.

Edge cases #

Versioning fails at the seams. Plan for these before they page you.

Strict version mismatch on a singleton. When a remote demands React 18.3 and the host’s share scope only has 18.2, strictVersion throws during init. This is correct behaviour — but it must not take down the whole shell. Wrap each remote mount in an error boundary so the failure is contained to one panel.

Partial upgrades with incompatible minors. During a staggered rollout, host and remote can briefly disagree on a relaxed (non-strict) dependency. Keep a compatibility matrix that records which remote minor works against which host minor, and gate the manifest update on it.

Manifest fetched but a version is gone. A remote’s latest points at a build whose files were purged or never finished uploading. The loadContainer rejection must trigger a fallback to prevStable, not an unhandled rejection.

Major-version coexistence. Two consumers need remoteUI v1 and v2 at once during migration. Expose versioned entries (/v1/remoteEntry.js, /v2/remoteEntry.js) under distinct federation container names so their share scopes don’t collide.

Contract drift inside the same range. The remote keeps ^2.x but changes a prop. SemVer alone won’t catch this — it needs explicit contract enforcement, the subject of Backward-Compatible Remote API Contracts.

A range too strict to ever resolve. Two sibling remotes pin requiredVersion: '18.2.1' and '18.2.3' (exact, no caret) with singleton + strictVersion. Only one version can win the share scope, so whichever loads second throws unconditionally — the system can never satisfy both. The fix is always caret ranges (^18.2.0) so a single registered patch satisfies every consumer.

A range too loose to be safe. A remote declares requiredVersion: '*' or '>=18' to “avoid version errors”. It silences the throw and then runs against React 19 internals it was never built for, surfacing as corrupted hooks rather than a clean failure. A loose range is not a fix; it just moves the crash somewhere harder to read. Keep the range tight enough to be meaningful and rely on the manifest for flexibility.

A CDN or service worker pinning a stale version. A service worker cached the old version-manifest.json, or an edge node serves it with a lingering TTL, so the host keeps resolving a version you already rolled back. Because the chunks are immutable the host loads cleanly — it just loads the wrong build, which is harder to spot than a 404. Serve the manifest no-store, exclude it from any service-worker precache (network-first at most), and add a short version field the host can log so you can see in telemetry which manifest revision each session actually used.

Debugging a version mismatch #

When a strictVersion error fires, walk it down in order:

  1. Compare the remote’s emitted shared versions against the host’s requiredVersion ranges.
  2. Confirm the manifest’s resolved URL points at the build you expect (check the version hash).
  3. Inspect __webpack_share_scopes__.default in DevTools to see which versions actually registered in the share scope.
  4. If the fallback chain ran out, verify a static placeholder is bundled in the host so users see a degraded panel, not a white screen.

Testing and validation #

Version compatibility is a CI concern, not a hope. Test the host against the matrix of remote versions you intend to support, and fail fast.

#!/usr/bin/env node
// scripts/validate-compatibility.js — runs the integration suite per remote version.
const { execSync } = require('node:child_process');

const versions = ['2.3.0', '2.4.0', '2.4.1']; // supported remote matrix

let failed = false;
for (const v of versions) {
  console.log(`Testing host against remoteUI@${v}`);
  try {
    // The integration suite reads REMOTE_VERSION to target a specific manifest entry.
    execSync(`REMOTE_VERSION=${v} npm run test:integration`, { stdio: 'inherit' });
    console.log(`${v} compatible`);
  } catch {
    console.error(`${v} broke the host contract`);
    failed = true;
  }
}
process.exit(failed ? 1 : 0);

Pair the matrix run with two checks that catch the silent failures: assert the exposed component’s prop contract with snapshot or schema tests, and run npm ls react react-dom plus a bundle analyzer pass to confirm no duplicate framework instance slipped into a build. The script simulates runtime loading by injecting the version the suite resolves against, so a contract break is caught in CI rather than in a user’s session.

Deployment #

The deployment model that makes all of this pay off is: immutable remote artifacts, a mutable pointer, and rollback by editing the pointer.

Rollback procedure #

# Roll remoteUI back to the last known-good version. No host rebuild.
aws s3 cp s3://cfg/version-manifest.json ./manifest.json
node -e "const m=require('./manifest.json'); m.remoteUI.latest=m.remoteUI.prevStable; require('fs').writeFileSync('./manifest.json', JSON.stringify(m, null, 2));"
aws s3 cp ./manifest.json s3://cfg/version-manifest.json --cache-control no-cache
aws cloudfront create-invalidation --distribution-id ABC123 --paths "/cdn/version-manifest.json"

Because the immutable remote chunks for the previous version were never purged, reverting is just repointing latest and invalidating one JSON file. Hosts pick up the change on the next navigation or dynamic import — zero host redeployment.

Automated rollback triggers #

Manual rollback is the floor, not the goal. The same pointer-flip should fire automatically when health signals cross a threshold, so a bad version is gone before an on-call human reads the page. Wire the trigger to the signals that a version regression actually moves:

Keep the trigger idempotent and rate-limited: it should refuse to promote the same failing version twice, and it should hold at prevStable until a human clears the incident, otherwise an auto-promote pipeline and an auto-rollback trigger will flap against each other. The telemetry that feeds these thresholds — distributed traces, error-boundary signals, per-remote dashboards — is the subject of Deployment & Observability.

Common pitfalls #

Issue Root cause & resolution
Multiple React instances corrupting hooks/context singleton: true omitted or requiredVersion ranges that don’t overlap, so webpack loads a second copy. Enforce singleton: true for every stateful framework and keep ranges compatible across host and remotes.
Host 404s on a remote after the remote redeploys The host bundle hardcoded the remote’s remoteEntry.js URL. Resolve URLs at runtime from the manifest; never bake a version-specific path into the host build.
Stale remoteEntry.js served from cache Manifest or entry served with long TTL, so browsers load yesterday’s version. Serve the manifest with no-cache and put immutable, content-hashed filenames on everything it references.
Breaking prop/hook change inside the same SemVer range SemVer trusted without contract enforcement. Add CI contract tests on exposed module interfaces; bump major when the contract changes.
Pinned exact versions block security patches requiredVersion: '1.2.3' instead of a caret range stops automatic patch adoption. Use ^1.2.3 with automated dependency patch updates.
Rollback requires a full host redeploy Versions live in the host build. Move version selection to the manifest so rollback is a pointer change, not a pipeline run.
requiredVersion: '*' “fixes” version errors but corrupts state A range too loose accepts a major it was never tested against, so the throw moves to the API surface as broken hooks. Keep ranges meaningful (^18.2.0) and let the manifest, not the range, provide flexibility.
Exact pins on siblings make the share scope unsatisfiable Two consumers pin 18.2.1 and 18.2.3 exactly under strictVersion, so one always throws. Use caret ranges so a single patch satisfies every consumer.
Host loads the wrong (rolled-back) version after a revert A service worker or edge node cached the old version-manifest.json. Serve it no-store, keep it out of any precache (network-first), and log a manifest version so telemetry shows which revision each session used.
Framework range bump shipped as a minor breaks every host Moving requiredVersion from ^18 to ^19 changes who can host the remote — that is a breaking change. Treat a shared-dependency range bump as major: parallel entry plus a migration window.

FAQ #

How do I prevent version conflicts when several hosts consume the same remote?

Make the manifest the single source of truth and keep remote URLs out of every host build. Each host advertises a requiredVersion range and resolves the live URL at runtime, so all hosts negotiate against the same compatibility baseline instead of drifting on hardcoded paths. Keep strictVersion: true on shared frameworks so any real mismatch fails loudly rather than loading a duplicate.

Can I run two major versions of the same remote at once?

Yes, and you’ll need to during a migration. Publish versioned entries (/v1/remoteEntry.js, /v2/remoteEntry.js) under distinct federation container names so their share scopes stay separate. Route consumers to the version they were built against, then retire v1 once everyone has moved.

What should happen if a remote version fails to load at runtime?

Resolve a fallback chain: try the requested version, fall back to prevStable, then render a bundled static placeholder inside an error boundary. Emit telemetry on every fallback so an incident is visible immediately, and never let an unhandled rejection from a remote crash the host shell.

Do I really need a manifest, or can I just use latest?

A bare latest works until the day you need to roll back or run two versions side by side. The manifest costs almost nothing — a small JSON file — and buys you instant rollback, range-aware resolution, and major-version coexistence. Adopt it before you need it, not during an incident.

Does Module Federation 2.0 make manual version manifests unnecessary?

It makes the plumbing unnecessary, not the strategy. MF 2.0 emits mf-manifest.json per build and gives you init, loadRemote, and the errorLoadRemote hook, so you no longer inject script tags or hand-roll a fallback try/catch. But the runtime still loads whatever entry URL you hand it — the indirection layer that maps a name and range to an immutable URL, and the pointer you flip to roll back, are still yours to own. Use the 2.0 hooks as the place to put the resolver from this guide, not as a reason to skip it.

Should strictVersion be on in production?

Yes, for stateful shared frameworks (React, ReactDOM, your router, your state library). A strictVersion throw is a contained, observable failure you can catch in an error boundary and auto-roll-back on; a silent second instance is intermittent corruption that reproduces only for cache-warmed users and costs days to trace. Turn it off only for stateless utilities where a duplicate copy is harmless, and lean on caret ranges plus the manifest so the strict check almost never has to fire.