Version Resolver
How the Orchestrator decides which shared dependency versions to share, scope or skip — including share-scopes, the strict scope, dynamic init and priority rules.
When remotes disagree on which version of a shared dependency they want, someone has to decide. That's the version resolver. It runs once per initFederation, categorizes every shared external into a scope, picks a winner per scope, and tags the rest with share, skip or scope — which is exactly what the import-map builder needs to produce a working map.
How remotes declare what to share
Every remote has a federation.config.js that tells the bundler which dependencies to share. The output of the build is a remoteEntry.json sitting next to the remote's JavaScript:
dist/
└── mfe1/
├── remoteEntry.json
├── button.js
├── dependency-a.js
└── dependency-b.js
{
"name": "team/mfe1",
"exposes": [
{ "key": "./Button", "outFileName": "button.js" }
],
"shared": [
{
"packageName": "dep-a",
"outFileName": "dependency-a.js",
"requiredVersion": "~2.1.0",
"singleton": false,
"strictVersion": true,
"version": "2.1.1"
},
{
"packageName": "dep-b",
"outFileName": "dependency-b.js",
"requiredVersion": "~2.1.0",
"singleton": true,
"strictVersion": true,
"version": "2.1.2"
}
]
}
Four fields drive every resolver decision:
requiredVersion— the semver range this remote will accept.singleton— candidate for sharing across remotes (true) or scoped to this remote (false).strictVersion— what to do when the shared version falls outsiderequiredVersion: fall back to a scoped download (true), or accept the shared version with a warning (false).version— the actual version this remote ships.
Import maps, in one minute
An import map tells the browser where to fetch bare specifiers:
{
"imports": {
"react": "https://cdn.example.com/react@18.2.0.js",
"lodash": "https://cdn.example.com/lodash@4.17.21.js"
},
"scopes": {
"https://legacy-mfe.example.com/": {
"react": "https://legacy-mfe.example.com/react@17.0.2.js"
}
}
}
The critical limitation: one version per specifier per scope. You can't map "react" twice in imports. That's why the resolver exists — if three remotes want three React versions, only one can live in the global imports block; the rest either skip their own copy (using the shared one), or download privately inside their own entry in scopes.
Shared vs scoped externals
Shared externals — singleton: true
Candidates for deduplication. The resolver pools them by shareScope and picks a winner per scope; the chosen version lands in the import map, the rest are resolved as skip or scope.
Scoped externals — singleton: false
Never shared. Every remote gets its own copy in its own scope entry — no resolution needed, no interference with other remotes.
Secondary entrypoints — the entries map
A package can expose more than one import specifier: a primary entrypoint (@angular/core) and one or more secondary entrypoints (@angular/core/testing, @angular/core/rxjs-interop, …). Core v4.3.0 groups these under a single DenseSharedInfo, replacing the flat outFileName with an entries map from each specifier to its output file:
// In remoteEntry.json
{
"shared": [{
"packageName": "@angular/core",
"singleton": true,
"version": "20.0.0",
"requiredVersion": "^20.0.0",
"entries": {
"@angular/core": "core.js",
"@angular/core/testing": "core-testing.js"
}
}]
}
The resolver treats the whole entries map as one shared external: version negotiation happens once per package, and every specifier in entries follows the winning version's placement — scoped, share-scope, global, or a skip/override redirect. When a shared version wins, the union of everything its copies declare is the surface served to all consumers of that version, so each secondary entrypoint resolves to the same version as its primary.
Older/flat remote builds emit one SharedInfo per specifier instead of an entries map. Set feature.convertFlatSharedInfo to group them under their parent package (by npm scope) at runtime so they resolve together.
Copies of one version always merge
Several remotes can ship the same version of a package, and each bundles only the entrypoints it actually imports — so two copies of @angular/core@20.0.0 can declare { @angular/core } and { @angular/core, @angular/core/testing }. They built the same tag, so this is not a conflict: the version exposes the union, and each specifier is served by the first copy that declares it. Copy order is fixed by the orchestrator:
- The host's copy — already loaded in the browser and cannot be repointed.
- An already-served copy — repointing it would invalidate a committed import map and force a redundant download.
- The widest copy — the one declaring the most entrypoints, so the fewest builds are needed.
- Arrival order — ties keep the incumbent, so the generated import map stays byte-stable.
Merging within a version is unconditional: no copy is pushed out of sharing because it bundles more than the others, and the coverage settings below never apply here.
Entrypoint coverage and tearing
What the merged entries do not guarantee is that they cover every specifier a consumer needs. A remote deduped onto the shared version can import a secondary entrypoint that no copy of that version contains:
@angular/core 20.0.0 share mfe-a entries { @angular/core }
@angular/core 20.1.0 skip mfe-b entries { @angular/core, @angular/core/testing }
Serving those two specifiers from two different versions is a tear. It is harmless for most libraries, but it can break packages whose secondary entrypoints share module-singleton state with the primary. Three behaviours are available, in precedence order:
| Setting | What happens to an entrypoint the shared version cannot cover |
|---|---|
strict.strictEntryPointCoverage | Throws. The resolver refuses to share a package it cannot serve coherently. |
profile.scopeUncoveredEntrypoints | Scopes. The uncovered copy is split off and serves its whole entries bunch from its own build. The copies that are covered keep sharing. |
| neither (default) | Self-fills. The specifier is served from the declaring remote's own build and a warning is logged. Nothing is lost; the package tears. |
Both settings only govern tears between versions — copies of the shared version itself always merge, whatever you set. Scoping is per remote copy, not per version: given a shared surface { table, sort }, a deduped { table } and a deduped { table, paginator }, only the third is split off.
To keep tears rare in the first place, coverage is also a tiebreaker when picking the shared version: among candidates that tie on the extra-downloads heuristic, the resolver prefers the one that leaves the fewest specifiers uncovered. A decisive extra-downloads winner is never overridden.
Inside a pool a tear cannot happen at all, whichever of the three is configured — pooling checks coverage per specifier itself, and a remote that no single build covers takes its whole family from its own build.
Share scopes
By default, all singleton: true externals compete in a single global pool (__GLOBAL__). The shareScope property carves out sub-pools for dependency groups that should be resolved together but stay isolated from everyone else.
// Team A — share UI components v3.x
{
"shared": [{
"packageName": "ui-components",
"singleton": true,
"shareScope": "team-a",
"version": "3.1.0",
"requiredVersion": "^3.0.0"
}]
}
// Team B — share UI components v2.x
{
"shared": [{
"packageName": "ui-components",
"singleton": true,
"shareScope": "team-b",
"version": "2.5.0",
"requiredVersion": "^2.0.0"
}]
}
// Global — React is shared across everyone
{
"shared": [{
"packageName": "react",
"singleton": true,
"version": "18.2.0",
"requiredVersion": "^18.0.0"
}]
}
Resolution is scope-local: team-a and team-b never try to pick a single UI-components version between them. The resolver picks a winner inside each scope and writes the resolved URL into every member remote's scopes entry in the import map — which is how the orchestrator emulates "grouped sharing" in a format that, strictly speaking, doesn't have groups.
The "strict" share scope
shareScope: "strict" is a special value. Instead of picking one winner per package, the resolver lets every provided version of that package coexist — each at its exact version, stripped of its requiredVersion range. Remotes still share when their versions match exactly; otherwise, each gets its own file.
// Team A — Angular 15.2.1
{
"shared": [{
"packageName": "@angular/core",
"singleton": true,
"shareScope": "strict",
"version": "15.2.1",
"requiredVersion": "15.2.1"
}]
}
// Team B — Angular 15.2.3
{
"shared": [{
"packageName": "@angular/core",
"singleton": true,
"shareScope": "strict",
"version": "15.2.3",
"requiredVersion": "15.2.3"
}]
}
// Both teams receive their exact Angular version — no patch-level mismatches.
When to use it:
- Compiled frameworks (Angular's AOT output) where patch versions can break compatibility.
- Dependencies whose breaking changes don't follow semver (internal APIs consumed by generated code, binary modules, WebAssembly).
- Gradual migrations where old and new versions must coexist.
Trade-offs:
- No automatic resolution — every declared version is downloaded.
- More bandwidth than regular shareScopes.
- Requires team coordination: strict scopes don't fix incompatibilities, they just make them explicit.
The resolution algorithm
Step 1 — Categorize
For every external in every fetched remoteEntry.json:
singleton: false→ individual scoped external. No resolution.singleton: true& noshareScope→ global shared pool, markeddirty: true.singleton: true&shareScope: "name"→ named shared scope, markeddirty: true.singleton: true&shareScope: "strict"→ strict scope, written directly with actionSHARE(not marked dirty — there is nothing for Step 2 to decide).
Version validation
An external's version is optional and may be missing or non-semver. Before it is stored, an invalid version is handled in precedence order — the result is always valid semver:
- Throw — if
strict.strictExternalVersionis on, an invalid or missing version raisesNFError. - Skip — else if
profile.skipInvalidExternalVersionsis on, the external is dropped and never stored. - Coerce (default) — otherwise the version is coerced to the smallest version matching the external's
requiredVersionrange.
await initFederation(manifest, {
// Drop externals with a missing/invalid version instead of coercing them.
profile: { skipInvalidExternalVersions: true },
});
Step 2 — Resolve each dirty scope
For every package in every dirty scope:
- If the scope contains exactly one version → action
SHARE. Done. - If the scope is
"strict"→ every version gets actionSHARE;requiredVersionis replaced with the exactversion. - Otherwise, pick the shared version using the priority rules below, then assign an action to every other version:
- Compatible with the shared version →
SKIP(reuse the shared URL). - Incompatible +
strictVersion: true+strictExternalCompatibilityon → throwNFError. - Incompatible +
strictVersion: true→SCOPE(download individually). - Incompatible +
strictVersion: false→SKIP+ warning (use the shared version anyway).
- Compatible with the shared version →
A verdict belongs to the copy, not the version. requiredVersion and strictVersion are per-build settings, so two remotes shipping the same tag can disagree about the winner. When they do, the version is split: only the copies that actually reject the shared version get SCOPE; the rest stay SKIP and keep deduping. The winner itself is never split, and a copy declaring strictVersion: false always dedups — that is what the flag asks for.
Step 3 — Generate the import map
| Scope | Action | Where it goes |
|---|---|---|
| Global | SHARE | Root imports — visible to every remote. |
| Named share scope | SHARE | Added to each member remote's entry in scopes — same URL, many scopes. |
| Strict scope | SHARE | Added to the requesting remote's entry in scopes, once per exact version. |
| Any | SCOPE | Added to the requesting remote's entry in scopes with its own URL. |
| Any | SKIP | Omitted — the remote falls back to the shared URL from a broader scope. |
Dependency Pooling
The algorithm above resolves every shared external independently: each one picks its own shared version, sourced from whichever remote contributed the winning tag. Packages that ship as a family are not independent, so this can hand a remote a combination nobody built — @acme/ui@3.4.0 from one remote beside @acme/tokens@3.2.0 from another, both "compatible" by their declared ranges. The sharper case is transitive: a design system compiled against @framework/core@15, shared into a remote running core@16, drags a second framework runtime in behind it.
Pooling is the opt-in feature that prevents this. It groups coupled externals — automatically by npm scope, or explicitly through a pool tag on a shared external — and makes each remote take the whole group from a single build that shipped them together, its own included. It is a re-resolution layered on top of the resolution above: it rewrites the verdicts, but elects no versions of its own and grants no dedup the resolver did not.
await initFederation(manifest, {
feature: { useAutoExternalPooling: true },
});
Two gates decide, per remote: a remote the resolver marked scope on any member is islanded onto its own build for the whole family, and every remaining remote must either already be resolving through builds that shipped what it imports, dedup onto one build that covers it whole, or serve its own family. Pooling applies to the global scope and named share scopes; the strict scope is never pooled, and a warm init that re-elects nothing does no pooling work.
Pooling buys coherence, not downloads — on every portfolio measured it left the download count unchanged or increased it, never reduced it.
See Dependency Pooling for the full picture: whether you need it, how membership is decided, both gates in detail, the measured cost, tagging guidance for unscoped lockstep families, and the diagnostics.
Dynamic init — adding remotes after the fact
Dynamic init currently requires useShimImportMap({ shimMode: true }) — native import maps can only be committed to the DOM once.
Dynamic init lets you load an additional remote after initFederation has finished. It is additive only: it can add new dependencies to existing scopes, but it cannot replace, modify or remove anything the initial pass resolved.
What dynamic init does
For every new external on the newly-loaded remote:
singleton: false→ straight to scoped externals.singleton: true, package not in scope yet → actionSHARE, becomes the shared version.singleton: true, already in scope, strict scope → actionSHARE, added as an extra exact version.- Otherwise, compatible with the existing shared version →
SKIP(reuse). - Incompatible +
strictVersion: true→SCOPE(download individually). In strict mode (strictExternalCompatibility), this path throws instead. - Incompatible +
strictVersion: false→SKIP(reuse the existing shared version). No warning is emitted and strict mode does not throw — the remote opted out of the compatibility check.
A new partial import map is then appended to the DOM — the existing one is never rewritten.
Dynamic init actions, summarized
| Action | Meaning |
|---|---|
SHARE | No compatible version exists yet — become the shared version for this scope. |
SKIP | Use the existing shared version. In a shareScope, this is the mechanism that lets additional remotes join. |
SCOPE | Incompatible and strict — download a private copy. |
Example scenario
// Initial setup
const { initRemoteEntry, loadRemoteModule } = await initFederation({
'team/header': 'http://localhost:3000/remoteEntry.json',
'team/sidebar': 'http://localhost:4000/remoteEntry.json',
});
// Later — add a new MFE at runtime
await initRemoteEntry('http://localhost:5000/remoteEntry.json', 'team/dashboard');
// It is now loadable
const Dashboard = await loadRemoteModule('team/dashboard', './Dashboard');
Given this initial state:
// team/header: react@18.2.0 (global)
// team/sidebar: design-system@3.1.0 (shareScope "team-a")
And a dashboard declaring:
{
"shared": [
{ "packageName": "react", "version": "18.1.0", "requiredVersion": "^18.0.0", "singleton": true },
{ "packageName": "design-system", "version": "3.0.5", "requiredVersion": "^3.0.0", "singleton": true, "shareScope": "team-a" },
{ "packageName": "charts-library", "version": "2.4.0", "singleton": true }
]
}
Outcome:
- react@18.1.0 →
SKIP(compatible with the existing 18.2.0 global). - design-system@3.0.5 →
SKIP(compatible with existing 3.1.0 in team-a). - charts-library@2.4.0 →
SHARE(new package, becomes the global shared version).
New import map appended to the DOM:
{
"imports": {
"charts-library": "http://localhost:5000/charts-library@2.4.0.js"
},
"scopes": {
"http://localhost:5000/": {
"design-system": "http://localhost:4000/design-system@3.1.0.js"
}
}
}
Dynamic init constraints
- Cannot replace existing shared versions. Loading React 17 after React 18 was resolved either reuses React 18 (with a warning) or falls back to a scoped React 17 if
strictVersion. - Cannot re-assign scopes. An external resolved globally cannot move to a share scope, and vice versa.
- Never re-resolves. Dirty flags stay
false; existing resolutions are preserved exactly.
Use cases
- Route-based loading — load a remote when the user navigates to a feature.
- Feature flags — only pay for advanced analytics when the user has them.
- A/B testing — pick a remote URL per variant.
Scope levels, side by side
| Scope | Config | Import map | Typical use |
|---|---|---|---|
Global (__GLOBAL__) | singleton: true, no shareScope | imports | React, common utilities — the things everyone should agree on. |
| Named share scope | singleton: true, shareScope: "name" | One resolved URL inserted into each member remote's entry in scopes | Team-specific libraries, design systems, domain clusters. |
| Strict scope | singleton: true, shareScope: "strict" | Each exact version added per-remote in scopes | Compiled frameworks, breaking-change-heavy packages, binary modules. |
| Individual scope | singleton: false or incompatible + strict | Per-remote entry in scopes with its own URL | Incompatibilities, remote-private dependencies. |
The dirty flag
When a remote is processed, every shared dependency it contributes to is marked dirty: true. At resolution time, only dirty entries are re-resolved. After resolution, everything is marked clean again. On a warm page load — where most entries are read from sessionStorage — nothing is dirty, so resolution is a no-op and the cached import map can be reused directly.
strictVersion in detail
strictVersion only matters for singleton: true. It governs what happens when the shared version in the scope is incompatible with this remote's requiredVersion.
strictVersion: false — default
// MFE wants ui-lib ~4.16.0, shared version is 4.17.0
{ "packageName": "ui-lib", "version": "4.16.5", "requiredVersion": "~4.16.0",
"singleton": true, "shareScope": "team-a", "strictVersion": false }
// Result: SKIP + WARNING. Remote uses shared 4.17.0. May break at runtime.
strictVersion: true
// Same config, strictVersion flipped
{ "packageName": "ui-lib", "version": "4.16.5", "requiredVersion": "~4.16.0",
"singleton": true, "shareScope": "team-a", "strictVersion": true }
// Result: SCOPE. Remote gets its own ui-lib@4.16.5 download. Guaranteed compat, extra payload.
Priority rules
For every non-strict scope, the resolver picks one version as "the shared version". It checks these rules in order:
1. Host version override
If hostRemoteEntry provides a version of this package in this scope, it wins. Unconditionally.
await initFederation(manifest, {
hostRemoteEntry: { url: './host-remoteEntry.json' },
});
// Host declares react@18.0.5 → wins over:
// MFE1 react@18.2.0 (global)
// MFE2 react@18.1.0 (global)
2. Latest version strategy
Opt-in via profile.latestSharedExternal: true. Picks the highest semver version in the scope, regardless of download cost.
await initFederation(manifest, {
profile: { latestSharedExternal: true },
});
// Available: [18.1.0, 18.2.0, 18.0.5] → picks 18.2.0
3. Optimal version strategy — default
Picks the version that minimizes extra scoped downloads within the scope. For each candidate version, count how many other versions would be forced to SCOPE (incompatible + strict) if that candidate were the shared one, then pick the candidate with the lowest cost.
// If 18.2.0 is chosen:
// 18.1.0 compatible → SKIP (0 extra downloads)
// 17.0.2 incompatible + strict → SCOPE (1 extra download)
// Total cost: 1
// Picks 18.2.0.
4. Caching strategy
When sessionStorage or localStorage holds a previously-resolved version, the resolver prefers it — even if a newer candidate would be technically "better" by the optimal strategy. This is what makes multi-page hosts actually share downloads across navigation.
Remote cache override behavior
When a remoteEntry.json is already cached, should the orchestrator refetch it?
Default rule: override only when the remote name is the same but the URL changed (e.g. .../v0.0.1/remoteEntry.json → .../v0.0.2/remoteEntry.json). Dynamic init always skips by default.
await initFederation(manifest, {
profile: {
overrideCachedRemotes: 'never', // never refetch cached remotes
overrideCachedRemotes: 'init-only', // refetch only during initFederation (default)
overrideCachedRemotes: 'always', // refetch on every cycle, including dynamic init
},
});
To force a refetch even when the URL is identical (e.g. immutable URL with mutated content):
await initFederation(manifest, {
profile: {
overrideCachedRemotes: 'always',
overrideCachedRemotesIfURLMatches: true,
},
});
When a remote is overridden, the orchestrator performs a clean cache purge: old RemoteInfo, old shared chunks, old scoped externals and old shared externals (in every scope) are removed first, then the fresh remoteEntry.json is processed from scratch. Each of those is a whole-remote removal rather than a merge with what the replacement declares — a build that stops chunking a bundle omits the key instead of sending an empty list, so a per-bundle replace would leave the old chunk files mapped and 404-ing. Removing the host's own copy also clears the host flag on that version, so a version the host has moved off can't keep host precedence.
Troubleshooting
Version conflicts
// Global scope in strict mode
NFError: [team/mfe1] dep-a@1.2.3 is not compatible with existing dep-a@2.0.0 requiredRange '^1.0.0'
// Shared scope in strict mode
NFError: [custom-scope.dep-a] ShareScope external has multiple shared versions.
Fixes, in rough order of preference:
- Loosen
requiredVersionranges on the clashing remotes. - Pin the version in
hostRemoteEntry. - Split the conflicting remotes into different share scopes.
- Use
shareScope: "strict"to let multiple exact versions coexist. - Disable
strictmode (last resort — accepts runtime compat risk).
Share scope without an override version
Warning: [team-a][dep-a] shareScope has no override version.
Every version in the scope is either incompatible with the others and marked strictVersion: true, or the scope contains misconfigured single-remote groups, or the declared ranges genuinely don't overlap. Either widen ranges, split the scope, or accept the individual downloads.
Strict scope with multiple versions
Info: Strict scope external design-tokens has multiple shared versions: 2.1.0, 2.2.0
That's expected behavior — that is what the strict scope does. Use it sparingly; consolidate versions when you can.
Semver compatibility
Compatibility checks use the standard semver rules:
| Range | Meaning | Examples |
|---|---|---|
^1.2.3 | Compatible changes | 1.2.4, 1.3.0, 1.9.9 |
~1.2.3 | Patch-level changes | 1.2.4, 1.2.9 |
>=1.2.3 | Greater than or equal | 1.2.3, 2.0.0 |
1.2.3 | Exact version | 1.2.3 only |
Pre-release versions are only considered compatible with matching pre-release ranges in the same scope.
See also
- The orchestrator docs — The orchestrator docs regarding the version resolver.
- Dependency Pooling — the opt-in layer that keeps a coupled package family coherent across remotes.
- Architecture — the caches the resolver reads and writes.
- Configuration — modes — every knob that tunes resolution behavior.
- Core — sharing dependencies — the build-side config that produces the inputs to this resolver.