Version Resolver

April 22, 2026 ยท View on GitHub

< back

Version Resolver

The version resolver determines how to share externals (dependencies) across multiple remotes (micro frontends). It decides which external versions to share globally, share within specific scopes, or scope to individual remotes (micro frontends).

How are the remotes bundled:

Native-federation provides a federation.config.js in its remotes. This configuration file allows the user to finetune which externals should be shared with other remotes and which should only be used by that specific remote. This process of choosing a specific (sub)set of remotes that can use a particular shared externals is called "scoping".

Whenever a remote is bundled, Native-federation includes a metadata file called the remoteEntry.json. When transpiled and bundled, a remote file structure looks like this:

๐Ÿ“ dist/
โ””โ”€โ”€ ๐Ÿ“ mfe1/
    โ”œโ”€โ”€ ๐Ÿ“„ remoteEntry.json
    โ”œโ”€โ”€ ๐Ÿ“„ button.js
    โ”œโ”€โ”€ ๐Ÿ“„ dependency-a.js
    โ”œโ”€โ”€ ๐Ÿ“„ dependency-b.js
    โ””โ”€โ”€ ๐Ÿ“„ chunk-ABCD1234.js

The remoteEntry.json contains a translation of the federation.config.js and serves as metadata file to explain to the orchestrator which remotes can be shared and which have to be scoped:

{
  "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",
      "bundle": "browser-dep-b"
    }
  ],
  "chunks": {
    "browser-dep-b": ["chunk-ABCD1234.js"],
    "mapping-or-exposed": []
  }
}

These properties are very important for the orchestrator, here is what they mean:

  • requiredVersion: The acceptable range of versions that this remote is compatible with.
  • singleton: Should the orchestrator share this external with other remotes or use it only for this remote?
  • strictVersion: Does the remote accept versions of this external that are outside of the accepted range (requiredVersion).
  • version: The version of the external.
  • bundle: (Optional) name of the internal chunk bundle this external belongs to, resolved via the chunks map on the same remoteEntry. See Shared Chunks for details.

Understanding Import Maps

The orchestrator creates an import map from the provided remote metadata files (remoteEntry.json). Externals can be shared globally, shared within specific groups (shared scopes), or scoped to individual micro frontends.

What is an Import Map?

An import map is a JSON structure that tells the browser where to find JavaScript ES module imports:

{
  "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"
    }
  }
}

When your code does import React from 'react', the browser uses this map to fetch the actual file.

Only one shared version per scope

A major drawback of import-maps is that they can only specify one version of each dependency per scope:

// โŒ This is NOT possible in import maps
{
  "imports": {
    "react": "https://cdn.example.com/react@18.2.0.js",
    "react": "https://cdn.example.com/react@17.0.2.js"  // Duplicate key!
  }
}

This limitation necessitates version resolution. When multiple micro frontends require different versions of the same dependency within a scope, only one can be shared "globally".

The Solution: Multiple Scope Levels

Import maps provide scopes as solutions for dependency management:

{
  "imports": {
    // Global scope - most micro frontends use this
    "react": "https://cdn.example.com/react@18.2.0.js",
    "ui-library": "https://cdn.example.com/ui-lib@2.1.0.js"
  },
  "scopes": {
    // Individual micro frontend scope
    "https://legacy-mfe.example.com/": {
      "react": "https://legacy-mfe.example.com/react@17.0.2.js"
    },

    // Linking multiple scopes to the same external can create a more fine-grained sharing of externals between a specific selection of remotes.
    "mfe1.example.com/": {
      "ui-library": "mfe1.example.com/ui-lib@3.0.0.js"
    },
    "mfe2.example.com/": {
      "ui-library": "mfe1.example.com/ui-lib@3.0.0.js"
    }
  }
}

How it works:

  • Global sharing: Most micro frontends use React 18.2.0 and UI Library 2.1.0
  • Individual scoping: Legacy MFE gets its own React 17.0.2
  • shareScope grouping: Design system MFEs share UI Library 3.0.0.

Specificity:

The order of precedence is based on the specificity of the scope, with the global import having the lowest precedence.

Note: With the "shareScope" grouping (3rd example), the import map is being tricked in loading the same file for 2 different scopes. This is handled by the orchestrator internally and provides a way to share an external over a select set of scopes. More on this later.

Shared vs Scoped Dependencies

In the remote's metadata file (remoteEntry.json), dependencies are marked as "externals". Every external contains configuration that determines how it should be shared.

Shared externals (singleton: true)

Dependencies marked as singleton: true are candidates for sharing:

// In remoteEntry.json
{
  "shared": [
    {
      "packageName": "react",
      "singleton": true,
      "version": "18.2.0",
      "requiredVersion": "^18.0.0"
    }
  ]
}

Result: This dependency is a candidate to be placed in the imports object (in the importmap).

Scoped externals (singleton: false)

Dependencies with singleton: false are always scoped to their individual remote:

// In remoteEntry.json
{
  "shared": [
    {
      "packageName": "lodash-utils",
      "singleton": false,
      "version": "1.0.0"
    }
  ]
}

Result: This external is placed in the scope of its remote. And therefore only available to that specific remote.

Shared scopes

By default, externals with the singleton: true property are shared globally between all remotes. The shareScope property can be used for externals that should only be shared over a select group of remotes. The shareScope property creates a logical group for dependency resolution. Externals with the same shared scope are resolved together in isolation from other share scopes.

This can be useful e.g. if some legacy remotes are still dependent on a previous major of a framework:

Internally, shared "scope groups" don't exist in import maps, therefore it is only possible through overriding the specific scopes with 'the same url'.

// Team A micro frontends - share UI components v3.x
{
  "shared": [{
    "packageName": "ui-components",
    "singleton": true,
    "shareScope": "team-a",
    "version": "3.1.0",
    "requiredVersion": "^3.0.0"
  }]
}

// Team B micro frontends - share UI components v2.x
{
  "shared": [{
    "packageName": "ui-components",
    "singleton": true,
    "shareScope": "team-b",
    "version": "2.5.0",
    "requiredVersion": "^2.0.0"
  }]
}

// Global shared dependency
{
  "shared": [{
    "packageName": "react",
    "singleton": true,
    "version": "18.2.0",
    "requiredVersion": "^18.0.0"
  }]
}

How shared scopes work:

  1. Resolution: Dependencies with the same shareScope are grouped and resolved together
  2. Sharing: The version within a logical group that is deemed to be most optimal for sharing is shared among all micro frontends in that logical group
  3. Import Map: Each micro frontend within the logical group gets the shared version added to its individual scope in the final import map

The "strict" shareScope

The special shareScope: "strict" shareScope enables exact version matching instead of semantic version range compatibility. This is useful when you need precise version control and want to share multiple specific versions of the same dependency.

// Strict sharing - only exact versions are matched
{
  "shared": [
    {
      "packageName": "ui-library",
      "singleton": true,
      "shareScope": "strict",
      "version": "2.1.1",
      "requiredVersion": "^2.1.0" // Will be replaced with exact version 2.1.1
    }
  ]
}

Differences compared to regular "share scopes":

While a regular shareScope (including "global") shares only the most compatible version and scopes the rest of the provided incompatible versions. The "strict" shareScope will share all provided versions. The shared versions will be stripped of their requiredVersion range and exposed as exact versions. This way, remotes can still share dependencies while receiving their own exact provided version. This is good for externals that have many breaking updates or incompatibilities between (patch) versions.

Example: Multiple exact versions sharing

// Team A - Angular 15.2.1
{
  "shared": [{
    "packageName": "@angular/core",
    "singleton": true,
    "shareScope": "strict",
    "version": "15.2.1",
    "requiredVersion": "15.2.1"  // Exact version required
  }]
}

// Team B - Angular 15.2.3
{
  "shared": [{
    "packageName": "@angular/core",
    "singleton": true,
    "shareScope": "strict",
    "version": "15.2.3",
    "requiredVersion": "15.2.3"  // Different patch, potential incompatibility
  }]
}

// Result: Both teams get their exact Angular version
// No runtime compatibility issues from mismatched compiled code

This prevents the runtime errors that occur when Angular's AOT-compiled code expects specific internal APIs that may have changed between patch versions.

When to use strict shareScope:

  • Compiled Frameworks: @angular/* related packages, where patch versions can break compatibility due to AOT compilation
  • Breaking Changes: When minor/patch versions introduce breaking changes despite semantic versioning
  • API Contracts: When exact version matching is required for API compatibility

Limitations:

  • No automatic version resolution - each remote gets exactly what it specifies
  • Potential for more downloads compared to compatible version ranges
  • Requires careful coordination between teams, especially when using angular modules as remote modules. This feature does not fix an incompatibility between remotes.

Resolution Process

The resolver creates an import map based on the provided metadata (remoteEntry.json) files, processing dependencies at multiple scope levels.

Step 1: Categorize Dependencies by Scope

flowchart LR
    A[Process remoteEntry.json] --> B{singleton: true?}
    B -->|Yes| C{Has shareScope?}
    B -->|No| D[Add to individual scoped externals]
    C -->|Yes| E[Add to shared scope externals]
    C -->|No| F[Add to global shared externals]
    E --> G[Needs scope-level resolution]
    F --> H[Needs global resolution]
    D --> I[No resolution needed]

Step 2: Resolve Dependencies by Scope

Dependencies are resolved separately for each scope:

// Input: Multiple scopes with different versions

Global scope:
  react@18.2.0 (requires "^18.0.0", singleton: true)
  react@18.1.0 (requires "^18.0.0", singleton: true)

"team-a" scope:
  ui-lib@3.1.0 (requires "^3.0.0", singleton: true, shareScope: "team-a")
  ui-lib@3.0.5 (requires "^3.0.0", singleton: true, shareScope: "team-a")

"team-b" scope:
  ui-lib@2.5.0 (requires "^2.0.0", singleton: true, shareScope: "team-b")

"strict" scope:
  design-tokens@2.1.0 (requires "2.1.0", singleton: true, shareScope: "strict")
  design-tokens@2.2.0 (requires "2.2.0", singleton: true, shareScope: "strict")

Individual scopes:
  lodash@4.17.21 (singleton: false)

Step 3: Resolution Algorithm

For each scope (global, shared scopes, strict, individual), the resolver determines one or more versions to share. The first step is to check wether the external should be shared or not:

flowchart TD
    A[Process remoteEntry.json files] --> B[For each external in shared array:]
    B --> C{singleton: true?}
    C -->|No| D[Add to individual scoped externals<br/>No resolution needed]
    C -->|Yes| E{Has shareScope property?}
    E -->|No| F[Add to global shared externals<br/>Mark as dirty: true]
    E -->|Yes| G[Add to named shared scope externals<br/>Mark as dirty: true]

    F --> H[Needs global resolution]
    G --> I[Needs scope-level resolution]
    D --> J[Ready for import map generation]

Determine Shared Versions

When the shared externals have been discovered, it is time for the resolver to determine which version to share of each shared external. This processs is partially based on the provided config of the user. There are multiple scopes, 1 global and 1 for each shareScope, the resolver loops through the scopes as follows:

flowchart TD
    A[For each scope with dirty externals] --> B{Only one version in scope?}
    B -->|Yes| C[Set action: SHARE]
    B -->|No| D{Scope type?}
    D -->|Strict scope| E[All versions get action: SHARE<br/>Keep exact requiredVersions]
    D -->|Other scopes| F[Choose optimal shared version]

    F --> F1{Host version exists?}
    F1 -->|Yes| F2[Choose host version]
    F1 -->|No| F3{latestSharedExternal enabled?}
    F3 -->|Yes| F4[Choose latest version]
    F3 -->|No| F5[Choose version with least extra downloads]

    F2 --> G[Assign actions to other versions]
    F4 --> G
    F5 --> G

    G --> G1[For each remaining version:]
    G1 --> G2{Compatible with shared version?}
    G2 -->|Yes| G3[Action: SKIP<br/>Use shared version]
    G2 -->|No| G4{strictVersion: true?}
    G4 -->|Yes| G5{strictExternalCompatibility enabled?}
    G4 -->|No| G6[Action: SKIP<br/>Use incompatible shared version + warning]
    G5 -->|Yes| G7[Throw NFError]
    G5 -->|No| G8[Action: SCOPE<br/>Download individually]

    C --> H[Resolution complete]
    E --> H
    G3 --> H
    G6 --> H
    G8 --> H

Step 4: Generate Import Map

The resolver creates different import map sections based on scope and actions:

flowchart LR
    A[Resolution Results] --> B{Scope Type}
    B -->|Global Scope + SHARE| C[Add to *imports* property]
    B -->|Shared Scope + SHARE| D[Add to scope in *scopes* property]
    B -->|Strict Scope + SHARE| E[Add to individual MFE scope in *scopes*]
    B -->|SCOPE| F[Add to individual scope in *scopes*]
    B -->|SKIP| G[Omit from map or get overridden by SHARE]

    C --> H[Available to all micro frontends]
    D --> I[Available to micro frontends in shared scope]
    E --> J[Available to specific requesting micro frontend]
    F --> K[Available only to specific micro frontend]

Dynamic Init

Important!: This feature currently only works with the use-import-shim import-map type.

Dynamic init is a runtime feature that allows loading additional micro frontends after the initial federation setup is complete. This is useful for lazy-loading micro frontends on demand or adding micro frontends based on user interactions or application state.

Key Characteristics/limitations

Additive Only: Dynamic init can only add new dependencies to existing scopes - it cannot replace, modify, or remove dependencies that were resolved during the initial setup.

Non-Disruptive: The dynamic init process preserves all existing dependency resolutions and import map entries. Cached dependencies from the initial setup remain unchanged.

Scope Aware: Dynamic init respects the same scoping rules as the initial resolution process, adding new dependencies to their appropriate global, shared, or individual scopes.

This is in line with the ideology behind import maps. source | Shopify article

How Dynamic Init Works

When you call initRemoteEntry() to dynamically load a micro frontend, the system follows these steps:

flowchart TD
    A[Call initRemoteEntry] --> B[Fetch remoteEntry.json]
    B --> C[Process new dependencies]
    C --> D{external.singleton?}
    D -->|No| E[Add to scoped externals]
    D -->|Yes| F{Dependency already exists in scope?}
    F -->|No| G[Action: SHARE<br/>Become shared version]
    F -->|Yes| H{Scope type?}
    H -->|Strict| I[Action: SHARE<br/>Add as additional exact version]
    H -->|Other| J{Compatible with existing shared version?}
    J -->|Yes| K[Action: SKIP<br/>Use existing shared version]
    J -->|No| L{strictVersion: true?}
    L -->|Yes| M[Action: SCOPE<br/>Individual download]
    L -->|No| N[Action: SKIP + WARN<br/>Use existing incompatible]

    N --> O{strict mode enabled?}
    O -->|Yes| P[Throw NFError]
    O -->|No| Q[Continue with warning]

    E --> R[Add additional import-map to DOM]
    G --> R
    I --> R
    K --> R
    M --> R
    Q --> R

Dynamic Init Actions

Each new dependency gets one of these actions during dynamic init:

ActionDescription
SKIPVersion already exists or use existing shared version. In a shareScope context this action is used for overriding by skipping the provided external and loading a compatible cached version instead.
SHARENo compatible version exists (yet), become the shared version for this scope
SCOPEIncompatible version with strictVersion: true

Example: Dynamic Loading Scenario

// Initial setup
const { initRemoteEntry } = await initFederation({
  'team/header': 'http://localhost:3000/remoteEntry.json',
  'team/sidebar': 'http://localhost:4000/remoteEntry.json',
});

// Later, dynamically load a new micro frontend
await initRemoteEntry('http://localhost:5000/remoteEntry.json', 'team/dashboard');

// The dashboard MFE becomes available
const DashboardComponent = await loadRemoteModule('team/dashboard', './Dashboard');

Initial Setup Dependencies

// team/header - React 18.2.0 (global scope)
{
  "shared": [{
    "packageName": "react",
    "version": "18.2.0",
    "singleton": true
  }]
}

// team/sidebar - Design System 3.1.0 (team-a scope)
{
  "shared": [{
    "packageName": "design-system",
    "version": "3.1.0",
    "singleton": true,
    "shareScope": "team-a"
  }]
}

Dynamic Init - New Dashboard MFE

// team/dashboard - added dynamically
{
  "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
    }
  ]
}

Resolution Results

flowchart LR
    A[React 18.1.0] --> B[SKIP<br/>Use existing 18.2.0 globally]
    C[Design System 3.0.5] --> D[SKIP<br/>Use existing 3.1.0 URL from team-a]
    E[Charts Library 2.4.0] --> F[SHARE<br/>Become shared version globally]

Resulting Import Map Changes

Before Dynamic Init:

{
  "imports": {
    "react": "http://localhost:3000/react@18.2.0.js"
  },
  "scopes": {
    "http://localhost:4000/": {
      "design-system": "http://localhost:4000/design-system@3.1.0.js"
    }
  }
}

ImportMap that will be 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 Dependencies

If a dependency is already shared globally during the initial setup, dynamic init cannot replace it with a different version. For example, if React 18.2.0 is shared globally, dynamically loading React 17.0.0 with strictVersion: false will still use React 18.2.0 and show a warning. If strictVersion: true is used, the micro frontend will get its own scoped copy of React 17.0.0.

Cannot Modify Scope Assignments

Dynamic init cannot change the scope of a shared dependency. If a dependency like design-system@3.1.0 is shared in the "team-a" scope, it cannot be moved to the global scope or another shared scope during dynamic loading.

Dirty Flag Always False

Dynamic init sets dirty: false for all dependencies because it never modifies existing resolutions:

// Dynamic init behavior
ports.sharedExternalsRepo.addOrUpdate(packageName, {
  dirty: false, // Always false - no re-resolution needed
  versions: [...existingVersions, newVersion],
});

Best Practices for Dynamic Init

1. Design for Additive Loading

Structure your application so dynamic MFEs complement rather than conflict with initial setup:

// โœ… Good: Progressive enhancement
Initial: Core navigation + basic React
Dynamic: Dashboard with charts, analytics widgets

// โŒ Problematic: Conflicting versions
Initial: React 18.x + Modern UI library
Dynamic: Legacy MFE requiring React 17.x + Old UI library

2. Use Shared Scopes Strategically

Group related MFEs in shared scopes to maximize reuse during dynamic loading:

// โœ… Good: Team-based scopes
"team-dashboard": { "ui-components": "3.x" }
"team-reports": { "ui-components": "3.x" }

// Later dynamic loading within same team reuses components

3. Handle Loading Failures Gracefully

try {
  await initRemoteEntry('http://dashboard-team.com/remoteEntry.json', 'dashboard');
  // Dashboard is now available
} catch (error) {
  console.warn('Dashboard MFE failed to load:', error);
  // Application continues without dashboard features
}

4. Monitor Compatibility Warnings

Dynamic init may produce warnings for version mismatches:

// Enable logging to catch compatibility issues
await initFederation(manifest, {
  logLevel: 'warn',
  logger: consoleLogger,
});

// Watch for warnings like:
// "WARN: dashboard.react@18.1.0 using existing shared version 18.2.0"

Use Cases for Dynamic Init

Route-Based Loading

// Load MFEs based on navigation
router.on('/dashboard', async () => {
  await initRemoteEntry('http://dashboard.com/remoteEntry.json', 'dashboard');
  const Dashboard = await loadRemoteModule('dashboard', './Dashboard');
});

Feature Flags

// Load additional features based on user permissions
if (user.hasFeature('advanced-analytics')) {
  await initRemoteEntry('http://analytics.com/remoteEntry.json', 'analytics');
}

A/B Testing

// Load different versions for testing
const variant = getABTestVariant();
await initRemoteEntry(`http://variant-${variant}.com/remoteEntry.json`, 'test-mfe');

Understanding Scope Levels

Global Scope (__GLOBAL__)

  • Purpose: Dependencies shared across all micro frontends
  • Use case: Core libraries like React, common utilities
  • Configuration: singleton: true without shareScope
  • Import map: Added to the imports property

Shared Scopes (custom names)

  • Purpose: Logical groupings for dependency resolution among specific micro frontends
  • Use case: Team-specific libraries, design systems, domain-specific tools
  • Configuration: singleton: true with shareScope: "scope-name"
  • Import map: Resolved version URL is added to each MFE's individual scope

Strict Scope ("strict")

  • Purpose: Exact version matching without semantic version compatibility checking
  • Use case: Multiple exact versions of the same dependency, legacy support, breaking changes
  • Configuration: singleton: true with shareScope: "strict"
  • Import map: Each exact version URL is added to requesting MFE's individual scope
  • Unique behavior: Multiple versions can have the "share" action simultaneously

Individual Scopes (per micro frontend)

  • Purpose: Dependencies used only by one micro frontend
  • Use case: Incompatible versions, micro frontend-specific libraries
  • Configuration: singleton: false or incompatible shared dependencies
  • Import map: Added to the specific MFE's scope with its own URL

Understanding "dirty" Flag

When processing remoteEntry.json files, shared dependencies are marked as "dirty" when new versions are added or their version list changes. This signals that the dependency needs resolution within its scope.

sequenceDiagram
    participant Step2 as Step 2: Process RemoteEntries
    participant Storage as Storage
    participant Step3 as Step 3: Determine Versions

    Step2->>Storage: Add react@18.2.0 to global scope
    Storage->>Storage: Mark global react as dirty: true
    Step2->>Storage: Add ui-lib@3.1.0 to team-a scope
    Storage->>Storage: Mark team-a ui-lib as dirty: true
    Step3->>Storage: Find all dirty dependencies in all scopes
    Storage-->>Step3: global react: dirty=true, team-a ui-lib: dirty=true
    Step3->>Step3: Resolve each scope separately
    Step3->>Storage: Mark all resolved dependencies as dirty: false

Why this matters: The dirty flag prevents unnecessary re-resolution of dependencies that haven't changed within their scope, improving performance when the same micro frontends are loaded repeatedly.

Understanding "strictVersion"

The strictVersion flag applies to shared dependencies (singleton: true) and determines how incompatible versions are handled within each scope:

strictVersion: false (default)

The user will be notified about the incompatible version, but the resolver will skip this version since another version was already shared in the scope.

// MFE needs ui-lib ~4.16.0, but team-a scope shares 4.17.0
{
  "packageName": "ui-lib",
  "version": "4.16.5",
  "requiredVersion": "~4.16.0",
  "singleton": true,
  "shareScope": "team-a",
  "strictVersion": false
}

// Result: SKIP + WARNING
// The MFE will use the shared 4.17.0 version URL from team-a scope
// May cause runtime compatibility issues

strictVersion: true

// MFE needs ui-lib ~4.16.0, but team-a scope shares 4.17.0
{
  "packageName": "ui-lib",
  "version": "4.16.5",
  "requiredVersion": "~4.16.0",
  "singleton": true,
  "shareScope": "team-a",
  "strictVersion": true
}

// Result: SCOPE (individual)
// The MFE gets its own ui-lib@4.16.5 download
// Guaranteed compatibility, but extra download

Note: strictVersion is ignored for scoped dependencies (singleton: false) since they always get their own copy.

Priority Rules Explained

1. Host Version Override

Host remoteEntry.json has the highest precedence within each scope. When an external version exists in the host remoteEntry.json for a specific scope, it is guaranteed chosen as the shared version for that scope.

await initFederation(manifest, {
  hostRemoteEntry: { url: './host-remoteEntry.json' },
});

// If host specifies react@18.0.5 globally, it wins over:
// - MFE1's react@18.2.0 (global)
// - MFE2's react@18.1.0 (global)

// If host specifies ui-lib@3.0.0 for team-a scope, it wins over:
// - Team A MFE1's ui-lib@3.1.0 (team-a scope)
// - Team A MFE2's ui-lib@3.0.5 (team-a scope)

2. Latest Version Strategy

Can be activated with the profile.latestSharedExternal hyperparameter. This changes the strategy within each scope from "most optimal" to "latest available" version.

await initFederation(manifest, {
  profile: { latestSharedExternal: true },
});

// Available versions in global scope: [18.1.0, 18.2.0, 18.0.5]
// Chosen: 18.2.0 (latest in global scope)

// Available versions in team-a scope: [3.0.5, 3.1.0, 3.0.8]
// Chosen: 3.1.0 (latest in team-a scope)

3. Optimal Version Strategy (default)

Why this is default: Minimizes bundle size and download time by choosing the version that requires the fewest additional scoped downloads within each scope.

The resolver calculates which version minimizes extra downloads per scope by examining how many versions would need to be individually scoped due to incompatibility:

// The resolver calculates which version minimizes extra downloads per scope:

Global scope - 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 extra download

Team-a scope - if 3.1.0 is chosen:
  3.0.5: compatible (SKIP) โ†’ 0 extra downloads
  Total cost: 0 extra downloads

Result: Choose 18.2.0 globally, 3.1.0 for team-a scope

4. Caching Strategy

The resolver optimizes for applications with page reloads. When storage like sessionStorage is chosen, shared dependencies are cached across page loads within their respective scopes:

sequenceDiagram
    participant Page1 as Page Load 1
    participant Resolver as Version Resolver
    participant Storage as Storage
    participant Page2 as Page Load 2

    Page1->>Resolver: Process dependencies by scope
    Resolver->>Storage: Mark versions as cached per scope
    Note over Storage: Global: react@18.2.0: cached=true<br/>team-a: ui-lib@3.1.0: cached=true

    Page2->>Resolver: Process dependencies
    Resolver->>Storage: Check cached versions by scope
    Storage-->>Resolver: Cached versions found per scope
    Resolver->>Page2: Prioritize cached versions within scopes

Remote Cache Override Behavior

When a remote is already present in the cache, the orchestrator will skip the requested remote or override the existing cached remote based on the provided profile options.

Override Flag Detection

The orchestrator checks when a remote should be overridden or skipped by comparing the provided remoteName with the cached remoteName. If they match, the requested remoteEntry.json URL will be compared with the cached remoteEntry.json URL. By default, on initialization, the remote will be skipped if the URLs match and overridden if the URLs differ. Except for the dynamic init which will always skip by default.

Skip Cached Remotes Configuration

The overrideCachedRemotes setting controls whether to fetch remotes that already exist in cache. The default setting is "init-only" since it is generally not recommended to update the existing import-map after initialization:

await initFederation(manifest, {
  profile: {
    overrideCachedRemotes: 'never', // Do not override cached remotes
    overrideCachedRemotes: 'init-only', // Override only during the first initialization (default)
    overrideCachedRemotes: 'always', // Override all cached remotes
  },
});

URL Matching Behavior

The overrideCachedRemotesIfURLMatches setting provides additional control. Normally, it makes sense to only override the cached remote if the URL changed, like from https://my.cdn/mfe1/0.0.1/remoteEntry.json to https://my.cdn/mfe1/0.0.2/remoteEntry.json. However, it might be necessary to always override, even if the URL matches the previously cached url:

await initFederation(manifest, {
  profile: {
    overrideCachedRemotes: 'always',
    overrideCachedRemotesIfURLMatches: true,
  },
});

Note: the overrideCachedRemotes is generally meant as "override only if urls differ".

Override Processing Steps

When a remote is marked for override by the orchestrator (override: true), the system performs complete cache cleanup by purging all cached meta data like exposed modules and externals:

flowchart TD
    A[Remote marked as override] --> B[Remove from RemoteInfo cache]
    B --> C[Remove from ScopedExternals cache]
    C --> D[Remove from SharedExternals cache (all scopes)]
    D --> E[Add new RemoteInfo to cache]
    E --> F[Process new externals normally]

    F --> G{External Type}
    G -->|singleton: true| H[Add to SharedExternals]
    G -->|singleton: false| I[Add to ScopedExternals]

Configuration

Host Remote Entry

Specify a host remoteEntry.json to control critical dependencies across all scopes:

await initFederation(manifest, {
  hostRemoteEntry: {
    url: './host-remoteEntry.json',
  },
});

Host dependencies can specify shareScope to control specific logical shared scopes, or omit it to control global sharing. Host versions always take precedence within their respective scope.

Resolution Strategy

Hyperparameters to tweak the behavior of the version resolver across all scopes:

await initFederation(manifest, {
  // Use latest available versions in each scope
  profile: {
    latestSharedExternal: true,
  },

  // Skip cached remotes for performance
  profile: {
    overrideCachedRemotes: 'never',
  },

  // Fail on version conflicts in any scope
  strict: true,
});

Storage Options

Choosing different storage allows the library to reuse cached externals across page loads, maintaining scope-specific optimizations:

// In-memory only (default) - fastest, lost on page reload
storage: globalThisStorageEntry,

// Single session only - survives page reloads, cleared when browser closes
storage: sessionStorageEntry,

// Persist across browser sessions - survives browser restarts
storage: localStorageEntry

When to use each:

  • globalThis: Development or single-page visits where speed matters most
  • sessionStorage: Multi-page applications where users navigate between pages
  • localStorage: Frequently visited applications where long-term caching provides value

Scope impact: All storage options maintain the logical shared scope groupings and resolved version URLs for optimal performance.

Troubleshooting

Version Conflicts

// Error in strict mode for global scope
NFError: [team/mfe1] dep-a@1.2.3 is not compatible with existing dep-a@2.0.0 requiredRange '^1.0.0'

// Error in strict mode for shared scope
NFError: [custom-scope.dep-a] ShareScope external has multiple shared versions.

// Solutions:
// 1. Loosen the version constraints in the remoteEntry.json
// 2. Use host override for the dependency in the specific scope
// 3. Disable strict mode
// 4. Move conflicting dependencies to different shared scopes
// 5. Use strict shareScope for exact version control

Shared Scope Issues

// Warning for shared scope with no shared versions
Warning: [team-a][dep-a] shareScope has no override version.

// All versions in the shared scope will be individually scoped
// Consider reviewing version compatibility or shared scope assignments

Common causes:

  • All versions in the logical shared scope are incompatible with each other and have strictVersion: true
  • Misconfigured shared scope names leading to single-version groups
  • Version ranges that don't overlap within the logical group

Strict Scope Considerations

// Multiple exact versions in strict scope
Info: Strict scope external design-tokens has multiple shared versions: 2.1.0, 2.2.0

// This is expected behavior - each exact version gets its own download
// Consider if version consolidation is possible to reduce bundle size

Best practices for strict scopes:

  • Use sparingly to avoid version sprawl
  • Consider if regular shareScopes with looser version ranges could work
  • Document exact version requirements clearly for your team
  • Monitor bundle size impact of multiple exact versions

Common scenarios requiring strict scopes:

  • Angular applications: Patch versions can break compatibility due to AOT compilation
  • Compiled frameworks: Any framework with compilation steps that create version-specific artifacts
  • Binary dependencies: Native modules or WebAssembly that require exact version matching
  • Legacy migrations: Gradually migrating from old to new versions without compatibility risks

Semver Compatibility

The resolver uses standard semantic versioning rules within each scope:

RangeMatchesExamples
^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 compatible with the same pre-release range within the same scope.