Semantic Versioning for Module Federation Remotes #

Semantic versioning only protects a federated system when you apply it to two surfaces at once: the remote’s published contract (the URL and manifest a host resolves) and the requiredVersion ranges on every shared dependency that hosts and remotes negotiate at runtime.

Get one wrong and the failure mode is asymmetric. A too-strict range starves the runtime of a provider; a too-loose range lets a breaking minor slip through and crash a host component after it has already mounted.

This guide walks through versioning the remoteEntry, publishing a manifest the host reads, and tuning ^ / ~ / exact ranges so shared singletons resolve the highest compatible version. It is the practical counterpart to the broader Versioning Strategies for Remote Apps guide, and it assumes you already keep your exposed surface stable using backward-compatible remote API contracts.

Runtime version negotiation A host with requiredVersion caret 18.2.0 evaluates three remote-provided React versions and picks the highest that satisfies the range. Host shared: react requiredVersion ^18.2.0 Remote A provides 18.1.0 — rejected Remote B provides 18.2.0 — ok Remote C provides 18.3.1 — chosen Shared scope picks highest Negotiation resolves a single singleton: [email protected] 18.1.0 fails the caret floor; 19.x would fail the major ceiling
The shared scope evaluates every offered version against the host's range and instantiates the highest that satisfies it.

Prerequisites #

Step 1 — Version the remoteEntry behind an immutable path #

The host loads a remote by URL, so the URL must encode the major version. Build each remote release into a directory named for its version and never overwrite it.

# Remote build & publish (CI)
VERSION=$(node -p "require('./package.json').version")   # e.g. 2.3.1
MAJOR=${VERSION%%.*}                                      # -> 2

npm run build
aws s3 cp dist/ "s3://cdn-bucket/checkout/v${MAJOR}/${VERSION}/" \
  --recursive --cache-control "public, max-age=31536000, immutable"

This gives you two stable handles: /checkout/v2/remoteEntry.js (the floating “latest 2.x”) and /checkout/v2/2.3.1/remoteEntry.js (an exact pin). Because every exact path is immutable, hosts and CDNs can cache it forever.

Step 2 — Map exposed-API changes to a MAJOR bump #

Treat the modules listed in exposes as a public API. Any change that an existing host cannot consume without code changes is a MAJOR bump and a new /vN/ path.

// rollup-of-rules your CI version check enforces
// MAJOR: remove an exposed module, rename it, remove a prop, change a prop type,
//        change a default export shape, tighten a peer requirement.
// MINOR: add an exposed module, add an optional prop, widen accepted input.
// PATCH: bug fix with identical contract.
// webpack.config.js (Remote — checkout v2)
const { ModuleFederationPlugin } = require('webpack').container;
const { version } = require('./package.json'); // 2.3.1

module.exports = {
  output: {
    // publicPath must point at the version-scoped directory so chunks resolve
    publicPath: `https://cdn.example.com/checkout/v2/${version}/`,
  },
  plugins: [
    new ModuleFederationPlugin({
      name: 'checkout',
      filename: 'remoteEntry.js',
      exposes: {
        './CartWidget': './src/CartWidget',
      },
      shared: {
        react: { singleton: true, requiredVersion: '^18.2.0' },
        'react-dom': { singleton: true, requiredVersion: '^18.2.0' },
      },
    }),
  ],
};

The remote advertises both what it provides (react 18.x via its own package.json) and what it requires (^18.2.0). Both numbers flow into the runtime shared scope.

Step 3 — Choose ^ vs ~ vs exact for requiredVersion #

requiredVersion is a semver range, and the range is what the runtime checks each offered version against. The character you pick decides how much drift the negotiation tolerates.

Range Satisfies Use when
^18.2.0 >=18.2.0 <19.0.0 Default for frameworks. Accepts new minors/patches, blocks the next major.
~18.2.0 >=18.2.0 <18.3.0 You trust patches but not new minors (cautious shared internal libs).
18.2.0 (exact) only 18.2.0 A library with no stable minor contract; pin until you re-verify.
>=18.2.0 any 18.2.0 or above, no ceiling Almost never — invites a breaking major into the singleton.

Pair the range with enforcement:

// shared options that change negotiation behavior
react: {
  singleton: true,        // exactly one instance in the shared scope
  requiredVersion: '^18.2.0',
  strictVersion: true,    // throw at runtime if NO offered version satisfies the range
}

With singleton: true, the scope keeps one react. With strictVersion: true, an unsatisfiable range fails loudly instead of silently falling back to a mismatched instance. Without strictVersion, the runtime warns and uses the highest available version even if it violates the range — convenient in dev, dangerous in production.

Step 4 — Publish a version manifest the host resolves #

Hardcoding /v2/ into the host bundle re-couples release cycles. Instead, publish a small manifest the host reads at runtime to translate a logical version request into a concrete URL.

{
  "checkout": {
    "latest": "2.3.1",
    "channels": { "stable": "2.3.1", "next": "3.0.0-rc.2" },
    "versions": {
      "2.3.1": { "url": "https://cdn.example.com/checkout/v2/2.3.1/remoteEntry.js" },
      "2.2.0": { "url": "https://cdn.example.com/checkout/v2/2.2.0/remoteEntry.js" },
      "3.0.0-rc.2": { "url": "https://cdn.example.com/checkout/v3/3.0.0-rc.2/remoteEntry.js" }
    }
  }
}
// src/resolveRemote.ts
type Manifest = Record<string, {
  latest: string;
  channels: Record<string, string>;
  versions: Record<string, { url: string }>;
}>;

export async function resolveRemoteUrl(
  remote: string,
  request: string = 'latest', // a channel, an exact version, or "latest"
): Promise<string> {
  // Short TTL on the manifest only; the remoteEntry URLs it points to are immutable.
  const manifest: Manifest = await fetch('/manifest.json', {
    cache: 'no-cache',
  }).then((r) => r.json());

  const entry = manifest[remote];
  if (!entry) throw new Error(`No manifest entry for ${remote}`);

  const version =
    entry.versions[request]?.url ? request
    : entry.channels[request]
    ?? (request === 'latest' ? entry.latest : request);

  const url = entry.versions[version]?.url;
  if (!url) throw new Error(`No URL for ${remote}@${request}`);
  return url;
}

The host pins to a channel (stable) rather than a number, so a remote can ship 2.3.2 by moving the stable pointer in one manifest write — no host rebuild. Because pointer flips bypass the immutable chunks, you only ever invalidate the manifest; see CDN cache invalidation for federated remotes for the caching contract that makes this safe.

Step 5 — Decide pin vs float per consumer #

Floating (channel-based) and pinning (exact-version) are both valid; choose per host based on blast radius.

// A high-traffic checkout host pins until it has verified a release.
const url = await resolveRemoteUrl('checkout', '2.3.1');

// A low-risk internal dashboard floats on the stable channel for auto-updates.
const url = await resolveRemoteUrl('checkout', 'stable');

Float to get patches and minors for free; pin when a regression in the remote would be expensive and you want an explicit promotion gate. A common middle ground is to float on stable in non-critical hosts and pin critical hosts to an exact version that you bump via PR.

Step 6 — Load the resolved remote and let the scope negotiate #

// src/loadRemote.ts
import { resolveRemoteUrl } from './resolveRemote';

declare const __webpack_init_sharing__: (scope: string) => Promise<void>;
declare const __webpack_share_scopes__: { default: unknown };

export async function loadCartWidget() {
  const url = await resolveRemoteUrl('checkout', 'stable');

  await __webpack_init_sharing__('default'); // register host's shared versions first
  await import(/* webpackIgnore: true */ url);

  const container = (window as any).checkout;
  await container.init(__webpack_share_scopes__.default); // remote registers its versions
  const factory = await container.get('./CartWidget');
  return factory().default;
}

After both sides register, the shared scope holds every offered react version. Negotiation picks the highest version that satisfies the strictest active range — exactly the selection shown in the diagram above.

Verification #

Confirm the host loaded the version you intended and that the singleton negotiation resolved as expected.

// In a browser console or an e2e assertion, inspect the shared scope.
const scope = (window as any).__webpack_share_scopes__.default;
console.log(Object.keys(scope.react)); // -> ["18.3.1"]  (one entry = singleton held)
console.log(scope.react['18.3.1'].from); // which container provided the winner

A single key under scope.react confirms one instance was negotiated. Cross-check the loaded remoteEntry.js URL in the Network panel against the manifest pointer:

# Confirm the manifest channel points where you think it does
curl -s https://cdn.example.com/manifest.json | jq '.checkout.channels.stable'
# -> "2.3.1"

If the network request shows /v2/2.3.1/remoteEntry.js and the shared scope shows exactly one React, host and remote are negotiating correctly.

Troubleshooting #

Symptom: “Unsatisfied version” error and no provider is loaded. Diagnosis: your requiredVersion is too strict for any offered version — for example the host demands ~18.3.0 but every remote provides 18.2.x. With strictVersion: true this throws instead of falling back. Fix: widen to ^18.2.0, or align the remote’s actual React version. Inspect __webpack_share_scopes__.default.react to see what versions were actually offered before the throw.

Symptom: host mounts, then crashes deep inside a remote component. Diagnosis: the range was too loose (or strictVersion: false), so a breaking version was accepted into the singleton — a >=18.0.0 range silently admitting a major API change. Fix: cap the major with ^, set strictVersion: true, and verify the offending dependency wasn’t widened during a careless merge.

Symptom: two copies of React in the page (hooks error, duplicate context). Diagnosis: a remote omitted singleton: true, or two remotes declared ranges with no overlapping satisfiable version, so the scope instantiated both. Fix: set singleton: true everywhere for the framework and ensure all ranges share a common satisfiable version. If you genuinely need two majors during a migration, isolate them under separate /vN/ remotes rather than the same singleton.

Symptom: host still loads the old remote after you flipped the channel. Diagnosis: the manifest was served with a long-lived cache header, so the pointer flip never reached the browser or CDN edge. Fix: serve manifest.json with Cache-Control: no-cache (or a very short TTL) while keeping the remoteEntry.js chunks immutable, then purge only the manifest at the edge.