Orchestrator 4.8.0: one build per package, no more stitching.Read the announcement and share your feedback →

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:

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, …). With denseExternals on, the core 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. Every specifier of a shared package is then served from one build, so each secondary entrypoint resolves to the same build 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.

One build per package

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 }. Their files are not interchangeable, though: a file binds its own imports where its build lives. So in every share scope (global and each named one) the orchestrator picks one serving build per package, and publishes every specifier of the package from that build alone.

A package is its primary plus any flat secondary externals (@angular/core/testing reported as an external of its own), so a dense @angular/core and a flat @angular/core/testing at the same tag are served from the same build too.

Which specifiers make up a package follows the records the builds publish. A dense record — its name and every specifier it lists — is one package, and so is a flat entry together with its npm name; records that share a name or a specifier merge, named after the shortest name among them. So a build that declares .package('@shared/components/table') and .package('@shared/components/collapse') with denseExternals on publishes two packages, each served from its own build, while a build that ships them together under @shared/components merges both into one. Each build declares for itself, so remotes can move to declared packages one at a time.

Declared packages that import each other run from one build only when they share a pool label; without one, one of them can load twice. Code two packages share through a chunk runs once per build they are taken from, so keep state shared between packages in a mapping of its own.

The host's build serves every package it ships. Otherwise the orchestrator picks the build that causes the fewest extra downloads, preferring one that ships every entrypoint the other remotes import. A pooled package takes its pool's elected build, and in the strict scope each version is served from one build of its own.

A served file binds its bare imports through the scope of the build that serves it, whoever imports it. When that build scopes one of those imports, a consumer of the file can bind the serving build's copy rather than the one it runs itself.

Preferring a dev build while debugging

In development, a statically served remote can win over the one you are working on, for instance on the name when both ship the same version, so a library edit never shows up. With profile.enableDebugging on, initFederation ranks a dev copy right after the host when it picks which copy of a version serves it: host > dev > cached > widest coverage > name. A copy is dev when its remoteEntry.json marks the external with dev, which a build in dev mode does.

Entrypoint coverage and tearing

The serving build does not always ship every specifier a consumer imports. A remote deduped onto the shared version can import a secondary entrypoint the serving build lacks:

@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 builds is a tear. It is harmless for most libraries, but it breaks packages whose secondary entrypoints share module-singleton state with the primary, which in Angular surfaces as NG0203. The orchestrator never tears a package: mfe-b runs both specifiers from its own build, and mfe-a's build keeps serving everyone else. The same holds for a copy of the shared tag that ships an entrypoint the serving build lacks, and for a remote whose flat secondary is scoped because its strictVersion range rejects that tag.

Keeping a package whole can cost downloads, and a remote that falls back runs its own instance of that package. Keep fallbacks rare by having every build publish the same entrypoints of tightly coupled packages:

Coverage is also a tiebreaker when picking the shared version: among candidates that tie on extra downloads, the resolver prefers the one that leaves the fewest specifiers uncovered. A decisive extra-downloads winner is never overridden.

Inside a pool the unit is the family. A remote no single build covers takes its whole family from its own build, not just the one package. See Dependency Pooling.

When the host's build lacks an entrypoint

An entrypoint the host's files import but its build never declared can't be read from the build, so whether the import resolves depends on the shape:

The fix is in the host's 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:

Trade-offs:

The resolution algorithm

Step 1 — Categorize

For every external in every fetched remoteEntry.json:

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:

  1. Throw — if strict.strictExternalVersion is on, an invalid or missing version raises NFError.
  2. Skip — else if profile.skipInvalidExternalVersions is on, the external is dropped and never stored.
  3. Coerce (default) — otherwise the version is coerced to the smallest version matching the external's requiredVersion range.
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 — except members of a pool, which the pool elects as one family before this step:

  1. If the scope contains exactly one version → action SHARE. Done.
  2. If the scope is "strict" → every version gets action SHARE; requiredVersion is replaced with the exact version.
  3. 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 + strictExternalCompatibility on → throw NFError.
    • Incompatible + strictVersion: true → SCOPE (download individually).
    • Incompatible + strictVersion: false → SKIP (use the shared version anyway).

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 — Pick each package's serving build

Per share scope, each package gets the one build it is served from — see One build per package.

Step 4 — Generate the import map

ScopeActionWhere it goes
GlobalSHARERoot imports — visible to every remote.
Named share scopeSHAREAdded to each member remote's entry in scopes — same URL, many scopes.
Strict scopeSHAREAdded to the requesting remote's entry in scopes, once per exact version.
AnySCOPEAdded to the requesting remote's entry in scopes with its own URL.
AnySKIPOmitted — the remote falls back to the shared URL from a broader scope.

Each specifier is published from its package's serving build.

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. A remote groups coupled externals by giving them a pool label in its remoteEntry.json, and pooling elects the group as one family: every remote takes the whole group from one build that shipped it together — the elected one, another remote's, or its own. Pooled externals skip the per-package resolution above.

// In remoteEntry.json
{ "packageName": "@framework/core", "version": "22.0.5", "requiredVersion": "^22.0.0", "pool": "framework" }

The election picks the build that serves the most remotes (always the host's, when the host ships the family); remotes it does not serve share another remote's build where one serves several of them, and the rest run their own family. Pooling applies to the global scope and named share scopes; the strict scope is never pooled, and a warm init with nothing dirty in a scope does no pooling work there.

Pooling chooses coherence first, then downloads. Electing the family as a whole can move a pooled family's global version, and a remote that would mix builds always pays for its own family.

See Dependency Pooling for the full picture: whether you need it, how membership is decided, both gates in detail, the measured cost, tagging guidance, what pooling stores, and the diagnostics.

Dynamic init — adding remotes after the fact

With native import maps (useDefaultImportMap()), dynamic init needs a browser that supports multiple import maps; otherwise use useShimImportMap({ shimMode: true }). See Configuration — Import-map implementation.

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

The loaded remote's shared externals are decided per package — a dense root and its flat secondaries together — against what the committed import map serves of that package in its scope:

  1. singleton: false → straight to scoped externals.
  2. shareScope: "strict" → SHARE, added as an extra exact version.
  3. The map serves none of the package → SHARE: the remote's build opens it and becomes its serving build.
  4. A strictVersion range rejects the tag the map serves → SCOPE with a warning; under strictExternalCompatibility the load fails instead. A range without strictVersion follows the map's tag silently.
  5. The map serves every entrypoint the remote ships, from one build → SKIP: the remote follows that build wholly.
  6. Otherwise (the map lacks an entrypoint the remote ships, or serves the package from two builds) → SCOPE: the remote runs the whole package from its own build.

Pooled families are judged afterwards: per pool, the loaded remote either resolves through the committed map or serves its own family (see Dependency Pooling — dynamic init).

A new partial import map is then appended to the DOM — the existing one is never rewritten. If the load fails, nothing it wrote is kept: the cache returns to its last commit, and a retry by name fetches the remote again.

Dynamic init actions, summarized

ActionMeaning
SHAREThe committed map serves none of the package in this scope — the remote becomes its serving build.
SKIPThe committed map serves every entrypoint the remote ships, from one build — the remote follows that build. In a share scope, this is the mechanism that lets additional remotes join.
SCOPEA strictVersion range rejects the map's tag, or the map can't serve the package whole — the remote runs the whole package from its own build.

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:

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

Use cases

Scope levels, side by side

ScopeConfigImport mapTypical use
Global (__GLOBAL__)singleton: true, no shareScopeimportsReact, common utilities — the things everyone should agree on.
Named share scopesingleton: true, shareScope: "name"One resolved URL inserted into each member remote's entry in scopesTeam-specific libraries, design systems, domain clusters.
Strict scopesingleton: true, shareScope: "strict"Each exact version added per-remote in scopesCompiled frameworks, breaking-change-heavy packages, binary modules.
Individual scopesingleton: false or incompatible + strictPer-remote entry in scopes with its own URLIncompatibilities, remote-private dependencies.

Don't serve a remote under the host's URL path when the host has scopes of its own (a named share scope, the strict scope or a singleton: false external). Import-map scopes match by URL prefix, so a specifier the remote's own scope doesn't map falls back to the host's scope before the global imports, and the remote binds the host's copy.

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 neither resolution nor pooling runs and the cached import map can be reused directly.

A warm page also keeps what it has downloaded: when a runtime load gave a remote its own copy of a package, later pages keep that copy rather than deciding the package again the way a cold init would. Clearing storage (clearStorage, an orchestrator upgrade or, with sessionStorage, a new tab) lets the next init decide from scratch.

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. Remote silently 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 causes the fewest extra downloads within the scope. The cost of a candidate is the number of copies that would have to keep their own build: copies whose own range rejects the candidate while strictVersion is set. A copy that accepts the candidate dedups, and so does one declaring strictVersion: false. A copy that is already cached from an earlier page load is already downloaded, so it costs nothing — which tends to keep a multi-page host on the version it already loaded.

// If 18.2.0 is chosen:
//   18.1.0 (2 remotes, both accept 18.2.0)    → SKIP              (0 extra downloads)
//   17.0.2 (3 remotes, 1 of them pinning ~17) → split: the pinner
//                                               SCOPEs, the other
//                                               two SKIP           (1 extra download)
//   Total cost: 1

// Picks 18.2.0.

Ties break toward the version a warm page already shares (keeping it saves a download), then toward the candidate that leaves the fewest entrypoints uncovered, and then toward the newest tag. So a newer non-strict version that an added remote ships is adopted on the next cold init — after clearStorage, an orchestrator upgrade or, with sessionStorage, in a new tab.

A large group can outvote a newer version. Many remotes pinned to an older tag can win over a few on a newer one, since that is what "fewest extra downloads" asks for. Use the host version or latestSharedExternal when the newest version must win.

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 never overrides a remote that is already initialized, since the committed import map cannot drop its imports.

await initFederation(manifest, {
  profile: {
    overrideCachedRemotes: 'never',     // never refetch cached remotes
    overrideCachedRemotes: 'init-only', // refetch only during initFederation (default)
    overrideCachedRemotes: 'always',    // deprecated: same as 'init-only'
  },
});

To force a refetch even when the URL is identical (e.g. immutable URL with mutated content):

await initFederation(manifest, {
  profile: {
    overrideCachedRemotes: 'init-only',
    overrideCachedRemotesIfURLMatches: true,
  },
});

Without overrideCachedRemotesIfURLMatches, a remote redeployed at the same URL keeps its stored remoteEntry.json over a cache kept across page loads (localStorage, sessionStorage): the page keeps resolving the old build. A cacheTag doesn't help, since it only applies to a remoteEntry.json that is fetched. Deploy each build at its own URL (a version or content hash in the path), or set this option. Under overrideCachedRemotes: 'never' neither helps.

Remotes a manifest no longer lists

By default (profile.mergeManifests: true) storage keeps what every page's init stored: pages may pass their own manifests, and a remote loaded through initRemoteEntry stays stored for the next page. A remote removed from every manifest therefore stays stored, and served, until clearStorage, an orchestrator upgrade or, with sessionStorage, the end of the tab.

With profile.mergeManifests: false, an init removes every stored remote its manifest doesn't list — remotes loaded through initRemoteEntry included — and re-resolves the shared versions they took part in. Use it only when every page passes the same complete manifest. With per-page manifests, each page removes the other pages' remotes and fetches them again on the next navigation, and tabs on different manifests remove each other's remotes from a shared localStorage.

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

See the outcome before decoding error messages. The Native Federation DevTools Packages tab shows, per package, which version was shared, who provides it, and every participant's declared range and strictVersion flag next to whether its own copy was selected. The Conflicts filter narrows the list to packages that resolved to more than one version.

Version conflicts

With strictExternalCompatibility on, a copy whose strictVersion range rejects the shared version fails init:

// Logged
[team/mfe1][dep-a@1.2.3] Is not compatible with requiredRange '^1.0.0' of shared dep-a@2.0.0.

// Thrown
NFError: External dep-a@1.2.3 could not be shared.

The same conflict during a dynamic init fails the load instead, or is logged as a warning when the flag is off:

[__GLOBAL__][team/dashboard] dep-a@1.2.3 Is not compatible with existing dep-a@2.0.0 requiredRange '^1.0.0'

Fixes, in rough order of preference:

  1. Loosen requiredVersion ranges on the clashing remotes.
  2. Pin the version in hostRemoteEntry.
  3. Split the conflicting remotes into different share scopes.
  4. Use shareScope: "strict" to let multiple exact versions coexist.
  5. Disable strict mode (last resort — accepts runtime compat risk).

Import map problems in a share scope

Two share-scope states are reported while the import map is built. With strictImportMap on, each logs an error and init throws NFError: Could not create ImportMap.:

[custom-scope.dep-a] ShareScope external has multiple shared versions.
[team-a][dep-a] shareScope has no override version.

Without it, the first is a warning and the second a debug message, and the map is built anyway. A scope with no override version means every copy in it was scoped, because the declared ranges don't overlap or each copy is pinned with strictVersion: true. Widen the ranges, split the scope, or accept the individual downloads.

Semver compatibility

Compatibility checks use the standard semver rules:

RangeMeaningExamples
^1.2.3Compatible changes1.2.4, 1.3.0, 1.9.9
~1.2.3Patch-level changes1.2.4, 1.2.9
>=1.2.3Greater than or equal1.2.3, 2.0.0
1.2.3Exact version1.2.3 only

Pre-release versions are only considered compatible with matching pre-release ranges in the same scope.

See also