Part 5 of an 8-part series on Native Federation v4 for enterprise architecture teams. New to the series? Start with Part 1: The Mental Model, Revisited.
Part 4 ended with three open questions about the v3 Classic Runtime:
- How do you share a library across a range of versions, instead of demanding exact matches?
- How do you make
singletonandstrictVersionactually do something? - How do you avoid fetching everything again on every page load?
The Orchestrator, @softarc/native-federation-orchestrator, is the v4 runtime built to answer them (Orchestrator docs). This article shows how it decides what loads, using the same example as Part 4, so you can compare the results directly. It also covers version 4.8.0, which changed how those decisions are made.
As in Part 4, I ran the Orchestrator's real code (releases 4.7.0 and 4.8.0) against sample files, so every import map below is real output.
How you use it
In Angular, you don't call the Orchestrator directly. The Angular adapter's initFederation() wraps it and sets sensible defaults (adapter source). Your main.ts looks the same as in Part 3.
The big change is what initFederation() gives back. It returns a loadRemoteModule function, which you pass into your app (in Angular, usually through an InjectionToken). In v3, you imported loadRemoteModule globally from the runtime package. That global import still works in v4, but it's marked deprecated.
How it works, in four steps
<!-- dev.to: upload d1-orchestrator-flow.png and replace the path above with its URL -->
-
Fetch every
remoteEntry.json, the host's and each remote's, or reuse them from the cache. - Store them in the Orchestrator's caches.
-
Decide what to share. This is the step the v3 Classic Runtime never had, and it involves two decisions:
- Decision 1: which version of each shared library wins.
- Decision 2 (new in 4.8.0): which build of that package everyone uses.
-
Write the import map and return
loadRemoteModule.
Decision 1: choosing the version
For each shared library, the Orchestrator follows a few simple rules (version resolver):
-
Only
singletonlibraries are negotiated. A library withsingleton: falsegives each remote its own copy, with no discussion. - The host's version wins, if the host uses the library.
-
If the host doesn't use it, the Orchestrator picks the version that causes the fewest extra downloads. (You can change this to "newest version wins" with the
latestSharedExternalsetting.) -
Then each remote is checked against the winner:
- If the winner fits the remote's
requiredVersion, the remote uses it. - If it doesn't fit and
strictVersion: true, the remote gets its own copy. - If it doesn't fit and
strictVersion: false, the remote uses the winner anyway.
- If the winner fits the remote's
So the settings from Part 3 finally matter:
| Setting | v3 Classic Runtime | v4 Orchestrator |
|---|---|---|
singleton |
Ignored | Decides whether a library is negotiated at all |
requiredVersion |
Ignored | Decides whether a remote can use the winning version |
strictVersion |
Ignored | Decides what happens when it can't: own copy (true) or use the winner anyway (false) |
version |
The only thing checked, and only for an exact match | Used to pick the winner |
Same example, different result
Let's take Part 4's example again: the same package.json files and the same remoteEntry.json files. The host has Angular 20.1.4, mfe1 has 20.1.4, mfe2 has 20.1.5, and all three accept ^20.1.0. The host's build includes every Angular part the remotes use (more on why that matters in the next section).
This is the import map the Orchestrator produced:
{
"imports": {
"@angular/core": "http://localhost:4200/angular-core-AAA.js",
"@angular/core/rxjs-interop": "http://localhost:4200/rxjs-interop-AAB.js",
"rxjs": "http://localhost:4200/rxjs-BBB.js",
"mfe1/./Component": "http://localhost:4201/Component-CCC.js",
"mfe2/./Component": "http://localhost:4202/Component-FFF.js"
}
}
There are no scopes at all. Every app uses the host's Angular, including mfe2, because 20.1.4 fits its ^20.1.0 range. Under the v3 Classic Runtime, mfe2 loaded its own Angular because 20.1.4 and 20.1.5 weren't an exact match.
Part 4's late plugin remote, built with Angular 20.1.6, gets the same treatment. Added after startup, it only contributed its own component, and used the host's Angular and RxJS:
{ "imports": { "plugin/./Widget": "http://localhost:4203/Widget-JJJ.js" } }
Part 4's page had three copies of Angular. This one has one.
Decision 2: one build per package (4.8.0)
To understand the 4.8.0 change, you need one more idea: a package has several parts. Angular's @angular/core package includes @angular/core, @angular/core/rxjs-interop and more. As Part 3 showed, each build only includes the parts its own code imports. So two builds of "Angular 20.1.4" can contain different files.
That creates a problem. What if the host's Angular is missing a part a remote needs?
Before 4.8.0: borrowing missing parts
Up to version 4.7.x, the Orchestrator borrowed the missing part from another build and mixed it in. It assumed that the same version always means the same files. The maintainers found that wasn't true for 7 of the 24 package versions they checked (4.8.0 announcement).
Mixed copies caused real bugs. In one example, toSignal() failed with NG0203 because a borrowed rxjs-interop file talked to a different Angular runtime than the rest of the component.
Here's what 4.7.0 produced when the host's build had only @angular/core, and mfe2 (20.1.5) also used rxjs-interop:
{
"imports": {
"@angular/core": "http://localhost:4200/angular-core-AAA.js",
"@angular/core/rxjs-interop": "http://localhost:4202/rxjs-interop-GGH.js",
"rxjs": "http://localhost:4200/rxjs-BBB.js"
}
}
The page's Angular is now the host's 20.1.4 core plus mfe2's 20.1.5 rxjs-interop: one copy, built from two different builds.
The v3 Classic Runtime could mix builds too. In my test, a remote on the exact same version as the host used the host's @angular/core together with its own rxjs-interop, inside its own scope.
In 4.8.0: one build serves each package
Version 4.8.0 replaces borrowing with a simple rule: every package is served from one build. In each share scope, the Orchestrator picks one complete copy of the package, called the serving build (one build per package):
- If the host built the package, the host's copy is chosen.
- If the host didn't, it picks the remote's copy that the most remotes can use, meaning the fewest extra downloads.
Then each remote is checked. If the serving build has every part the remote needs, the remote uses it. If not, the remote runs the whole package from its own build, in its own scope. Nothing is borrowed anymore.
Same files, run on 4.8.0:
{
"imports": {
"@angular/core": "http://localhost:4200/angular-core-AAA.js",
"rxjs": "http://localhost:4200/rxjs-BBB.js"
},
"scopes": {
"http://localhost:4202/": {
"@angular/core": "http://localhost:4202/angular-core-GGG.js",
"@angular/core/rxjs-interop": "http://localhost:4202/rxjs-interop-GGH.js"
}
}
}
Now mfe2 uses its own complete Angular 20.1.5, while RxJS is still shared. That's two copies of Angular, but each one is complete and consistent.
The trade-off
4.8.0 is safer, but it can load more. A second copy of Angular can't render its components inside the host's component tree. The maintainers measured about 0.4% more downloads on average, and import maps can grow up to twice as large when many remotes fall back to their own builds (4.8.0 announcement).
The good news: with the right build settings, you get the best of both, meaning one complete copy.
What to do now
1. Make the host's Angular complete with keepAll. It tells the build to include every part of a package, even unused ones, so the host's copy covers every remote. The config ng add generates already does this for @angular/core. From 4.8.0, .patch() accepts wildcards, so you can cover all Angular packages at once:
shared: fromPackageJson({ singleton: true, strictVersion: true, requiredVersion: 'auto', build: 'package' })
.patch(['@angular/*'], { includeSecondaries: { keepAll: true } }),
The cost is small. The browser only downloads a file when some code imports it. A real example from the docs: Angular's provideAnimationsAsync() loads part of @angular/animations with a dynamic import that the default build doesn't detect, so without keepAll the host's animations can silently never run (version resolver docs).
2. Turn on denseExternals, starting with the host. With features: { denseExternals: true }, each package is written into remoteEntry.json as one entry with one version and one range, instead of one entry per part. The Orchestrator then makes one clean decision per package. You can switch one app at a time. The maintainers expect v5 to make this the default, around the end of Q1 2027.
3. Keep build: 'package', which ng add already sets. It bundles each package on its own, so all its parts come from one bundling run.
4. Use convertFlatSharedInfo for remotes you can't rebuild yet. This host option (initFederation(manifest, { feature: { convertFlatSharedInfo: true } })) reads older remoteEntry.json files as if they were dense. It can't add parts a build left out, though. Only rebuilding with keepAll fixes that.
5. Keep remote ranges compatible with the host. If a remote's package.json says ^20.1.5 while the host is on 20.1.4, that remote can't use the host's Angular. I ran this case on 4.8.0:
- With
strictVersion: true,mfe2got its own Angular in its own scope. - With
strictVersion: false,mfe2simply used the host's 20.1.4, a version its range says it doesn't support. No error stopped it.
For teams coming from v3: your remotes don't need rebuilding to switch. The settings v3 ignored are already in their remoteEntry.json files, and they start working as soon as the host runs the Orchestrator.
A note for large monorepos: one team with about 980 shared monorepo libraries reported that 4.8.0 loaded 27 of them twice, without any console errors, so they stayed on 4.7.0 for now. The cause is how library names are read. The maintainer has proposed an opt-in fix before v5 (discussion). If you share many internal libraries through sharedMappings, test 4.8.0 carefully before upgrading.
Remotes added later, and caching
Remotes added later use initRemoteEntry(), returned by initFederation(). It follows the same rules as startup and only ever adds to the import map. You saw it with the plugin remote above. In v3, the only option was a one-off loadRemoteModule({ remoteEntry }) call.
Caching has a surprising default. The Orchestrator keeps its caches in memory, so they don't survive a page reload. To keep them across reloads, choose a storage option (configuration):
import { sessionStorageEntry } from '@softarc/native-federation-orchestrator/options';
initFederation('federation.manifest.json', { storage: sessionStorageEntry });
sessionStorageEntry lasts for the browser tab's session, and localStorageEntry survives closing the browser. v3 had no caching between page loads at all.
Watch out when you turn this on: a remote redeployed at the same URL keeps its old cached remoteEntry.json, even with a cacheTag. Either deploy each build at its own URL, for example a versioned path like /mfe1/1.4.2/remoteEntry.json, or turn on the overrideCachedRemotesIfURLMatches profile option (configuration).
Trade-offs to know
- The host always wins. A remote can't bring a newer compatible version to the page while the host has the package.
- Strict mode is off by default, and it's all or nothing. Out of the box, a version mismatch never throws an error. You can turn on strict mode, but it then applies to every shared library at once, not just to Angular.
Both are policy decisions, and policy is what Part 6 is about: share scopes, strict mode, and how to keep many independently deployed apps from drifting apart.
Series roadmap
- The Mental Model, Revisited: why Native Federation exists and what changed in v4
- Anatomy of the v4 Package Graph: Core, Adapters, Runtime and Orchestrator as separate layers
-
Build-Time, End to End: from
federation.configtoremoteEntry.json - The Classic (v3) Runtime: what it got right, and where it hit its limit
- The Orchestrator: how v4 decides what loads, and what changed in 4.8.0 (this article)
- Version Drift and Resolution Strategy: keeping many independently deployed apps in sync
- v3 vs. v4: a detailed comparison and a practical migration plan
- Reference Architecture: running Native Federation v4 in a regulated enterprise
Further reading
Official documentation
- Orchestrator overview
- Orchestrator: getting started
- Orchestrator: configuration
- Orchestrator: version resolver
- Version resolver docs in the repository (4.8.0), including "One build per package"
Source code
From the maintainers
- Orchestrator 4.8.0: one build per package (announcement)
- Auke van Oostenbrugge, Part II: Composing your own native-federation orchestrator
- Auke van Oostenbrugge, Part III: Improving Cache Utilization in Native Federation
Next in this series: **Version Drift and Resolution Strategy, keeping many independently deployed apps in sync.





