Taming Version Skew in Module Federation

A host application boots, mounts three independently deployed remotes, and within eleven minutes of runtime a support ticket lands: “Invalid hook call” errors scattered across the checkout flow. Nothing changed in the host. A remote team shipped a patch release of a UI library twenty minutes earlier. This is module federation version skew, and it is the single most common failure mode in production micro-frontend estates once you move past three or four independently deployable remotes. The problem is not federation itself; it is the assumption that shared dependency resolution behaves the same way in a distributed, independently-versioned runtime as it does in a monolithic bundle graph.
#The Shared Scope Collision Problem
Webpack’s module federation plugin (and its successor runtime, module-federation/enhanced) solves cross-application code sharing by exposing a shared scope object at runtime — effectively a registry mapping package names to loaded module factories, keyed by version. When Host A requests react@18.2.0 and Remote B has already registered react@18.3.1 as a singleton, the federation runtime must decide, at request time, which instance satisfies the consumer’s semver range. Get this wrong and you get one of two catastrophic outcomes: duplicate React instances causing hook context mismatches, or a runtime patch in a leaf remote silently breaking a host that has no visibility into that remote’s release cadence.
This is fundamentally a distributed systems problem wearing a frontend costume. You have independently deployed artefacts, no shared build step, and a runtime negotiation protocol that has to make correctness guarantees across trust boundaries it cannot fully inspect. Treating architectural patterns from service-oriented backends — contract versioning, negotiation, graceful degradation — is the only way to make this reliable at scale.
Layer 7 ZTNA: WireGuard & Go Architecture
#Architectural Breakdown: How Module Federation Resolves Shared Dependencies
Every federated container exposes a shared scope populated at container init time. The resolution algorithm, roughly, is:
- Each remote registers its shared dependencies against the global scope, tagged with version, singleton flag, eager flag, and a semver
requiredVersionrange. - When a consumer requests a shared module, the runtime scans the scope for a version satisfying the
requiredVersionrange. - If singleton: true is set and multiple incompatible versions are registered, the runtime picks the highest version already loaded and emits a console warning rather than throwing — this is the silent failure vector.
- If no compatible version exists, a new instance is fetched and registered, resulting in duplicate copies of the same library at runtime.
The critical architectural detail engineers miss: module federation does not perform a build-time compatibility check across remotes. There is no central registry validating that Remote B’s React 18.3.1 is compatible with Host A’s assumptions. Compatibility is asserted via semver ranges you configure manually, and it is only validated at runtime, in the browser, on the user’s device. Official documentation on the shared module resolution strategy is available in the Webpack Module Federation concept guide, but it deliberately leaves negotiation policy to the implementer.
#Singleton Enforcement and the Semver Satisfaction Algorithm
Singleton enforcement matters most for libraries with global mutable state or context-based APIs: React, React DOM, state management libraries, routing libraries. Non-singleton shared dependencies (utility libraries, date formatters) tolerate duplication with only a bundle-size penalty. The failure mode you are engineering against is specifically singleton violation under version skew — where two remotes, each individually correct, produce an incompatible runtime when composed by a host that neither team controls.

#Implementation Logic: Building a Version Negotiation Layer
The fix is not “pin everyone to the same version.” That reintroduces the coupled release train you adopted micro-frontends to escape. The fix is a formalised negotiation contract enforced through three layers: strict shared configuration, a runtime interception plugin, and a CI compatibility gate.
#Step 1 — Strict Mode Shared Configuration
Configure every remote and host to declare explicit semver ranges rather than relying on the default * wildcard the CLI scaffolds generate. Wildcards are the root cause of most incidents.
1// webpack.config.js — host application
2const { ModuleFederationPlugin } = require('webpack').container;
3
4module.exports = {
5 plugins: [
6 new ModuleFederationPlugin({
7 name: 'hostApp',
8 remotes: {
9 checkoutRemote: 'checkoutRemote@https://cdn.kby.tech/checkout/remoteEntry.js',
10 },
11 shared: {
12 react: {
13 singleton: true,
14 strictVersion: true,
15 requiredVersion: '^18.2.0',
16 eager: false,
17 },
18 'react-dom': {
19 singleton: true,
20 strictVersion: true,
21 requiredVersion: '^18.2.0',
22 },
23 },
24 }),
25 ],
26};Setting strictVersion: true forces webpack to throw at runtime rather than silently downgrading compatibility, converting a hidden hook-context bug into an immediate, loud, diagnosable error. This is a deliberate architectural trade-off: you are exchanging a soft failure (broken UI, no error) for a hard failure (visible error, no broken UI), which is almost always preferable in production federation.
#Step 2 — Runtime Plugin for Dynamic Resolution
For estates running the newer @module-federation/enhanced runtime, you can intercept resolution decisions programmatically rather than relying purely on declarative semver. This lets you implement canary routing, override query parameters, and telemetry on every negotiation event — essential for diagnosing skew incidents post-hoc.
1// runtime-plugin.js
2export default function versionTelemetryPlugin() {
3 return {
4 name: 'version-telemetry-plugin',
5 beforeRequest(args) {
6 console.debug('[MF] resolving', args.id);
7 return args;
8 },
9 resolveShare(args) {
10 const { shareScopeMap, scope, pkgName, version, resolver } = args;
11 const resolved = resolver();
12 if (resolved && resolved.version !== version) {
13 fetch('/api/telemetry/version-skew', {
14 method: 'POST',
15 body: JSON.stringify({
16 package: pkgName,
17 requested: version,
18 resolvedTo: resolved.version,
19 scope,
20 }),
21 });
22 }
23 return { value: resolved, args };
24 },
25 };
26}This plugin does not change resolution behaviour — it observes it. In practice, the observability layer is what turns version skew from an unpredictable production incident into a measurable, alertable metric with a defined SLO.
#Step 3 — CI Compatibility Gate
The negotiation contract is worthless if remotes can still deploy breaking changes without warning. Add a CI step that fetches the currently deployed host manifest and diffs proposed shared versions against the host’s declared requiredVersion ranges before allowing a remote deploy to proceed.

1#!/usr/bin/env bash
2# ci/check-mf-compat.sh
3HOST_MANIFEST=$(curl -s https://cdn.kby.tech/host/mf-manifest.json)
4PROPOSED_VERSION=$(node -p "require('./package.json').dependencies.react")
5
6node -e "
7 const semver = require('semver');
8 const manifest = $HOST_MANIFEST;
9 const range = manifest.shared.react.requiredVersion;
10 const ok = semver.satisfies('${PROPOSED_VERSION//^/}', range);
11 if (!ok) {
12 console.error('Incompatible react version for host contract: ' + range);
13 process.exit(1);
14 }
15"This converts an implicit runtime assumption into an explicit, enforced deployment gate — the same discipline you would apply to a gRPC contract or an OpenAPI schema on a backend service mesh
#Failure Modes and Edge Cases
Several edge cases surface repeatedly once module federation is running at scale across more than five remotes:
- Eager consumption conflicts — setting
eager: trueon a shared dependency forces it into the initial chunk, bypassing the async negotiation entirely. If two remotes both mark the same dependency eager with different versions, the build succeeds but the browser silently loads whichever container initialised first, producing non-deterministic behaviour across page loads. - SSR hydration mismatch — server-rendered hosts resolve shared scope at request time on the server, which may differ from the client-side CDN cache state if a remote deployed between the two phases. This produces hydration warnings that are extremely difficult to reproduce locally.
- Cascading singleton failure — a single remote pushing a major version bump on a singleton dependency without coordinating the host’s
requiredVersionrange causes every other remote sharing that scope to fail simultaneously, even remotes that made no changes. This is the micro-frontend equivalent of a noisy-neighbour incident. - Stale CDN edge caches — remoteEntry.js files cached beyond your deployment’s TTL cause hosts to negotiate against a manifest that no longer matches the deployed container, producing 404s on dynamically imported chunks.
The common thread across all four is that module federation failures are almost always invisible at build time and only manifest under specific runtime composition — meaning your CI pipeline can be fully green while production is broken. This is precisely why the negotiation observability layer in Step 2 is not optional at any meaningful scale.
#Scaling and Security Trade-offs in Module Federation
Beyond correctness, there are structural trade-offs to weigh once federation crosses organisational boundaries — multiple teams, multiple deployment cadences, potentially multiple CDNs.
- Strict singleton enforcement vs. availability —
strictVersion: trueimproves correctness but converts a version mismatch into a hard runtime crash for the end user rather than a degraded-but-functional experience. Consider a fallback UI boundary around every federated remote import. - Runtime negotiation overhead — dynamic resolution via the enhanced runtime adds a measurable per-navigation cost (typically 3–12ms depending on shared scope size) compared to static, build-time bundling. At high remote counts this is negligible against network latency, but worth profiling under load.
- Remote script integrity — because
remoteEntry.jsfiles are fetched from potentially separate origins, apply Subresource Integrity (SRI) hashes and a strict Content Security Policy restrictingscript-srcto your known CDN origins. An unpinned remote origin is a direct supply-chain attack surface. - Version pinning vs. team autonomy — tight semver ranges reduce skew incidents but reintroduce coordination overhead between teams, partially undermining the independence micro-frontends are meant to deliver. The negotiation-plus-telemetry approach above is explicitly designed to preserve autonomy while still surfacing incompatibility before users hit it.
- CDN cache invalidation strategy — use content-hashed filenames for all federated chunks and a short TTL (under five minutes) on the manifest file itself; this keeps deploy propagation fast without sacrificing chunk-level cache longevity.
None of these trade-offs are resolved by tooling alone. Module federation gives you the mechanism for runtime composition; the negotiation policy, observability, and CI gating described here are what turn that mechanism into a reliable distributed architecture rather than a recurring incident source.



