Orchestrator Architecture
The orchestrator turns a list of remote URLs into a live import map
the browser can resolve. This page walks through the moving parts: the
manifest, each remoteEntry.json, the internal caches it
builds in memory (or persistent storage), and the final import map the
browser actually sees.
The problem, in one paragraph
Host (or shell) applications want to mount micro frontends that other
teams build and deploy independently — without downloading their
dependencies multiple times. Two teams both needing React shouldn't
download React twice, three teams using the same design system
shouldn't each ship their own copy, and a legacy app stuck on React 17
shouldn't break the whole page. The orchestrator solves that at runtim
on init: it reads everyone's metadata, decides what can be shared and
what has to be isolated, and writes a single import map the browser
uses to resolve every import in every remote.
Runtime flow
A single initFederation call drives the whole lifecycle:
-
Discover. Read the manifest (inline object or URL
pointing to a JSON manifest) to know which remotes exist. The
quickstart.mjsbundle additionally reads a manifest out of a<script type="application/json" id="mfe-manifest">element before handing it toinitFederation. -
Fetch metadata. In parallel, fetch each remote's
remoteEntry.json. - Cache. Store the parsed exposes and shared externals in the internal caches, keyed by share-scope.
- Resolve. For every shared dependency, pick a winner per scope using the rules described in Version Resolver.
-
Write the import map. Commit it to the DOM via
setImportMapFn— either a native<script type="importmap">or theimportmap-shimform when running under es-module-shims. -
Expose loaders. Resolve the
initFederationpromise withloadRemoteModule,load,as,config,adaptersandinitRemoteEntry.
Steps 2–5 can happen again after initialization through dynamic init, but only additively — nothing resolved during the initial pass is ever rewritten.
Core concepts
1. Manifest — the service directory
The manifest maps remote names to remoteEntry.json URLs.
Nothing else. It's the one piece of configuration your host owns
end-to-end.
{
"shopping-cart": "https://example.cdn/shopping-cart/1.0.1/remoteEntry.json",
"user-profile": "https://example.cdn/user-profile/1.3.2/remoteEntry.json",
"payment-widget": "https://example.cdn/payment-widget/2.3.1/remoteEntry.json"
}
The directory that contains each remoteEntry.json becomes
that remote's scope URL. Every module path and every
scoped external is resolved relative to that directory — so publishing
a remote at
https://example.org/mfe1/remoteEntry.json means all of
its JS lives under https://example.org/mfe1/.
Inline manifests are fine for demos. In production, prefer serving the manifest from a discovery endpoint or feed service so you can roll out, A/B-test or feature-flag remotes without rebuilding the host.
2. remoteEntry.json — the component metadata
Every remote publishes one remoteEntry.json. It lists the
modules the remote exposes and the dependencies the remote wants to
share.
{
"name": "team/remote1",
"exposes": [
{ "key": "./comp-a", "outFileName": "component-a.js" }
],
"shared": [
{
"packageName": "dep-a",
"outFileName": "dep-a.js",
"version": "1.2.3",
"requiredVersion": "~1.2.1",
"strictVersion": false,
"singleton": true,
"bundle": "browser-dep-a"
},
{
"packageName": "dep-b",
"outFileName": "dep-b.js",
"version": "4.5.6",
"requiredVersion": "^4.1.1",
"strictVersion": true,
"singleton": false
}
],
"chunks": {
"browser-dep-a": ["chunk-ABCD1234.js"],
"mapping-or-exposed": []
},
"integrity": {
"component-a.js": "sha384-…",
"dep-a.js": "sha384-…",
"dep-b.js": "sha384-…",
"chunk-ABCD1234.js": "sha384-…"
}
}
The optional integrity map (added by
@softarc/native-federation when built with
features.integrityHashes: true) carries an SRI hash per emitted file,
keyed by outFileName. The orchestrator resolves these to
absolute URLs and emits them under the integrity block
of the generated import map — see
Security — Subresource Integrity
for the end-to-end trust chain.
Shared external properties
| Property | Description |
|---|---|
version |
The actual version this remote ships. |
requiredVersion |
The range this remote is willing to accept from a shared copy — the basis for compatibility checks. |
strictVersion |
When true, the remote refuses incompatible shared
versions and gets its own scoped copy instead of a warning.
|
singleton |
When true, the dependency is a candidate for
sharing across remotes. When false, it is always
scoped to this remote.
|
shareScope |
Group externals into logical clusters; the special
"strict" scope enables exact-version sharing. See
shareScope.
|
packageName |
The import specifier remotes use (e.g. 'react').
|
outFileName |
File name, relative to the remote's scope URL. |
bundle |
Optional name of the shared bundle this external belongs to — the key the orchestrator uses to look up sibling chunk files in the shared-chunks cache (v4 opt-in). |
dev |
Optional dev-mode metadata (original source path, etc). |
Internal caches
After fetching metadata, the orchestrator keeps four caches in memory
(and optionally mirrors them to
sessionStorage/localStorage). Everything the
resolver and the import-map builder need lives in these structures —
they're accessible via
@softarc/native-federation-orchestrator/sdk if you need
to introspect.
Remote information cache
A plain map of remote name → scope URL + exposed modules:
{
"shopping-cart": {
"scopeUrl": "https://ecommerce-team.com/",
"exposes": [
{ "moduleName": "./CartButton", "file": "cart-button.js" },
{ "moduleName": "./CartSummary", "file": "cart-summary.js" }
]
}
}
This is what
loadRemoteModule('shopping-cart', './CartButton') reads
to compute the final URL.
Shared externals cache
Dependencies with singleton: true become
shared externals. The cache groups them by
shareScope (with the special sentinel
__GLOBAL__ for unnamed scopes), then by package name.
Each entry is a list of candidate versions, each annotated with a
resolved action:
{
"shared-externals": {
"__GLOBAL__": {
"dep-a": {
"dirty": false,
"versions": [
{
"tag": "1.2.3",
"host": false,
"action": "share",
"remotes": [
{ "file": "dep-a.js", "name": "team/mfe1", "requiredVersion": "~1.2.1", "strictVersion": false, "cached": true },
{ "file": "dep-a.js", "name": "team/mfe2", "requiredVersion": "~1.2.1", "strictVersion": false, "cached": false }
]
},
{
"tag": "1.2.2",
"host": false,
"action": "skip",
"remotes": [
{ "file": "dep-a.js", "name": "team/mfe2", "requiredVersion": "^1.2.1", "strictVersion": true, "cached": false }
]
}
]
}
},
"custom-scope": {
"dep-c": { "/* … */": "…" }
}
}
}
The three possible actions — share, skip,
scope — come out of the resolver. The
dirty flag marks scopes whose version list changed since
the last resolution pass; only dirty scopes get re-resolved, which
keeps warm reloads fast.
Scoped externals cache
Dependencies with singleton: false are always scoped to
their remote. The cache is a simple map
remoteName → packageName → { tag, file, bundle? }; the
import-map builder later joins each file to the owning
remote's scopeUrl:
{
"scoped-externals": {
"team/mfe1": {
"dep-b": { "tag": "4.5.6", "file": "dep-b.js" }
}
}
}
Shared chunks cache
Opt-in, introduced in native-federation v4.
Shared chunks are an opt-in feature of Native Federation v4 —
remotes built without it simply won't populate this cache, and the
orchestrator falls back to loading each shared external as a single
file. Before this feature, chunks were added to the shared object as
separate externals which was very expensive to calculate.
When a v4 remote opts in, the builder can code-split a shared external — breaking a single package into multiple chunk files. The orchestrator then needs to know the sibling file names so dynamic imports inside that package resolve correctly. The shared chunks cache stores that list, keyed by remote and by the bundle the chunks belong to:
{
"shared-chunks": {
"team/mfe1": {
"browser-shared": ["dep-a.chunk-AAAA.js", "dep-a.chunk-BBBB.js"],
"angular-core": ["dep-b.chunk-CCCC.js"]
}
}
}
During import-map generation, these chunk files are added under a
dedicated chunk scope so every dynamic import() inside a
shared bundle lands on the right URL. Most consumers never touch this
cache directly — it exists to make opt-in multi-chunk shared externals
work transparently.
The generated import map
All four caches feed into a single import map that the orchestrator commits to the DOM before resolving the init promise:
{
"imports": {
// Exposed modules, addressable as "<remoteName>/<key>"
"shopping-cart/./CartButton": "https://ecommerce-team.com/cart-button.js",
"shopping-cart/./CartSummary": "https://ecommerce-team.com/cart-summary.js",
// Globally shared externals
"dep-a": "https://example.org/mfe1/dep-a.js"
},
"scopes": {
// Scoped externals — only visible inside one remote
"https://example.org/mfe1/": {
"dep-b": "https://example.org/mfe1/dep-b.js",
"dep-c": "https://example.org/mfe1/dep-c.js",
// Shared-chunk siblings (v4 opt-in) — resolved inside the remote's scope
"@nf-internal/chunk-IXOA6WTM": "https://example.org/mfe1/chunk-IXOA6WTM.js",
"@nf-internal/chunk-WDE5IQ2F": "https://example.org/mfe1/chunk-WDE5IQ2F.js"
},
// shareScope grouping — same URL reused across several scopes
"https://example.org/mfe2/": {
"dep-c": "https://example.org/mfe1/dep-c.js"
}
},
"integrity": {
// Only present for URLs whose remoteEntry.json published a hash
"https://ecommerce-team.com/cart-button.js": "sha384-…",
"https://example.org/mfe1/dep-a.js": "sha384-…"
}
}
Five patterns fall out of this structure:
-
Global sharing via
imports— one download for every consumer. -
Scoped isolation via
scopes— a specific remote gets its own copy without disturbing anyone else. -
Scope groups — the same URL reused in several
scopesentries. Import maps don't have a first-class "group of scopes" concept, so the orchestrator emulates it by writing the same file under each member scope. From the browser's perspective it's still one download (the URL is identical), but each remote resolves the import inside its own scope. -
Shared chunks — when a v4 remote opts into chunked
shared externals, each chunk sibling is registered inside the owning
remote's scope under an internal specifier (e.g.
@nf-internal/chunk-IXOA6WTM). Dynamic imports emitted by the bundler inside a shared package then resolve to the right sibling file without polluting the globalimports. -
Tamper-evident loads via the optional
integrityblock — populated only for URLs whoseremoteEntry.jsoncarried an SRI hash, fully enforced underuseShimImportMapand honored natively by browsers that have shipped the import-mapintegritykey (part of the import-maps spec, browser support is rolling out). URLs without a hash are simply omitted, matching the SRI spec. See Security — Subresource Integrity.
Caching & performance
Resolved metadata is worth keeping around. Between page loads, the
orchestrator can remember which remotes it already knows about and
which shared versions it already picked, so a navigation-triggered
reload doesn't re-fetch remoteEntry.json files the
browser already cached in its HTTP cache.
| Storage | Lifetime | Best for |
|---|---|---|
Memory (globalThisStorageEntry,
default)
|
Single page load | SPAs, development, testing |
| sessionStorage | Browser session | Multi-page server-rendered hosts — the main use case |
| localStorage | Until cleared | Aggressive caching across browser restarts; accept some staleness |
On top of storage choice, the resolver applies four optimization strategies:
-
Skip cached remotes. If a remote is already in
cache with the same
remoteEntry.jsonURL, don't refetch. Controlled byprofile.overrideCachedRemotes. - Reuse resolved versions. Which version was picked for each shared external last time survives the reload.
-
Host wins ties. A
hostRemoteEntryalways dictates the shared version for its scope. - Maximize sharing. The default resolution strategy picks the version that minimizes extra downloads (see Optimal Version Strategy).
Every knob above has a dedicated option on Configuration.
A worked example
An e-commerce host with three independently-owned remotes:
<script type="application/json" id="mfe-manifest">
{
"product-catalog": "https://catalog-team.com/remoteEntry.json",
"shopping-cart": "https://cart-team.com/remoteEntry.json",
"user-account": "https://account-team.com/remoteEntry.json"
}
</script>
What happens:
- Manifest processing — three remotes discovered.
-
Metadata collection — three
remoteEntry.jsonfetches, executed in parallel. - Dependency resolution — overlapping React, lodash and shared UI components are unified; incompatible entries fall into scoped downloads.
- Import map generation — one map, committed once before any module loads.
-
Runtime —
loadRemoteModule()calls now resolve synchronously against the import map.
What you get:
- Teams deploy on their own cadence without coordinating releases.
- Shared libraries download once and are reused across remotes.
- The browser HTTP cache keeps warm navigations near-instant.
Where to go next
- The orchestrator docs — The orchestrator docs regarding native-federation's architecture.
-
Version Resolver — the algorithm
behind
share/skip/scopedecisions. - Configuration — every option that changes the behavior described above.
-
Security & Subresource Integrity —
the Trusted Types policy that wraps the DOM sinks shown above and
the SRI trust chain across the manifest, every
remoteEntry.json, and the import map. -
v3 vs v4 — how the orchestrator
compares to the classic
@softarc/native-federation-runtime.