Part 6 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.
Micro frontends promise that teams can deploy on their own schedule. The price of that freedom is version drift: over weeks and months, the host and each remote slowly end up on different versions of the libraries they share.
No build can catch this. Each app is built alone, against its own lockfile, and passes its own tests. Drift only exists in the browser, at the moment the host and its remotes meet on one page. That's why it needs a strategy, not just a fix.
Part 5 showed how the Orchestrator decides what loads. This article is about policy: which settings to choose, how to group teams, and when to fail loudly. It's based on the latest stable releases: Orchestrator 4.8.0, core 4.8.1 and Angular adapter 22.2.3. As in Parts 4 and 5, every import map and log message below is real output from running the Orchestrator's own code.
Where drift comes from, and what v4 does with it
Drift rarely comes from a big decision. It comes from everyday things: one team runs npm install a week later than another, a dependency bot updates a lockfile, or a team bumps a range in package.json.
| Kind of drift | Example | What the Orchestrator does | Risk |
|---|---|---|---|
| Patch or minor, inside the range | host 20.1.4, remote 20.1.5, both ^20.1.0
|
The remote shares the host's copy | Low |
| A raised range floor | remote changes to ^20.1.5, host still on 20.1.4 |
Own copy with strictVersion: true; the host's version anyway with false
|
Medium |
| A missing part | the remote uses rxjs-interop, the host doesn't ship it |
The remote loads its own complete package | Medium |
| A family split | the remote's router was built for core 20.1.5, the host ships core 20.1.4 only | A silent mix, unless the family is pooled | High |
| A different major | remote on Angular 19, host on 20 | Own copy | Medium |
For comparison, the v3 Classic Runtime turned every row in this table into a duplicate copy, because it only shared exact version matches (Part 4). v4 absorbs the common, harmless drift on its own. The rest needs four decisions from you.
Decision 1: choose settings by kind of library
Not every shared library carries the same risk. A good default policy:
| Kind of library | Examples | Recommended settings |
|---|---|---|
| Frameworks |
@angular/*, react + react-dom
|
singleton: true, strictVersion: true, keepAll, and a pool label |
| Stateful libraries | stores, auth clients, design systems built on the framework |
singleton: true, strictVersion: true, and the framework's pool label for design systems |
| Stateless utilities |
date-fns, lodash-es
|
singleton: false, so each app keeps its own copy and nothing is negotiated |
| Internal monorepo libraries | sharedMappings |
Treat them like stateful libraries if they hold state |
Two rules of thumb sit behind this table:
-
Use
strictVersion: falseonly where a wrong version is harmless. Otherwise a remote can silently run against a version its range says it doesn't support, as Part 5 showed. - Don't share what doesn't need sharing. A small utility that's never shared can never drift.
Decision 2: pool your framework families
Here is the most dangerous kind of drift, because nothing reports it.
Version ranges understate how tightly framework packages are coupled. The official pooling docs give an example: Angular's packages declare ^22.0.0 ranges on each other, but @angular/[email protected] really requires @angular/[email protected] (pooling docs). Ranges say a mix is fine when it isn't.
I ran this case. The host ships @angular/core 20.1.4 only. mfe1 ships @angular/core and @angular/router, both 20.1.5. Every range is ^20.1.0. Without pooling, the Orchestrator decides each package on its own:
{
"imports": {
"@angular/core": "http://shell.example/core-20.1.4.js",
"@angular/router": "http://mfe1.example/router-20.1.5.js"
}
}
The router built for 20.1.5 now runs on core 20.1.4, a combination nobody ever built or tested. No warning is logged.
Pooling fixes this. You give packages that belong together the same pool label, and the Orchestrator then treats them as one family: each remote takes the whole family from one build. With pool: 'angular' on the same files:
{
"imports": { "@angular/core": "http://shell.example/core-20.1.4.js" },
"scopes": {
"http://mfe1.example/": {
"@angular/core": "http://mfe1.example/core-20.1.5.js",
"@angular/router": "http://mfe1.example/router-20.1.5.js"
}
}
}
mfe1 now runs its own complete Angular, and the Orchestrator says why:
[__GLOBAL__][pool:angular] 'mfe1' serves its own family: no elected build offers every entrypoint
it imports at a version it accepts (gap '@angular/router', closest '__NF-HOST__').
The warning even names the fix: the gap is @angular/router. Once the host also ships the router, mfe1 uses the host's whole family again, with one Angular and no warning.
To pool Angular, add the label to the same .patch() call that sets keepAll (wildcards work from 4.8.0):
shared: fromPackageJson({ singleton: true, strictVersion: true, requiredVersion: 'auto', build: 'package' })
.patch(['@angular/*'], { includeSecondaries: { keepAll: true }, pool: 'angular' }),
Two things to know before you add it:
- One remote's label affects everyone. Once any remote labels a package, the whole family is coordinated for every app on the page. One team can fix a problem for the whole portfolio, but they should tell the other teams first.
-
Label design systems too. A design system compiled against Angular belongs in the
angularpool. Otherwise, a shared copy of it can pull in a second Angular runtime through the back door (pooling docs).
Decision 3: draw your share scope boundaries
By default, every app negotiates in one global group. A named shareScope creates a separate group, and the "strict" scope switches negotiation off (version resolver docs).
I ran a common case: team A's two apps use @acme/ui 3.x, and team B's two apps are still on 2.x.
-
With one global scope, 3.2.0 wins. Team A shares it, but team B's
billingandinvoiceseach load their own copy. That's three copies. -
With
shareScope: 'team-a'and'team-b', each team negotiates inside its own group. That's two copies, one per major version.
With shareScope: 'strict', every exact version is kept side by side. In my test, two remotes on @acme/charts 5.0.1 shared one copy, and a third remote on 5.0.2 got its own. Nothing was negotiated.
When to use each:
- Global (the default): for anything that must be one instance on the page. That includes Angular and any service the host and remotes share.
- Named scopes: for a library where groups of apps legitimately sit on different major versions, such as a team mid-migration.
-
"strict": for a library where even patch versions break each other. Use it sparingly, because every version gets downloaded.
One caution: an app in a named scope gets its own instance of that library, separate from the global one. Never put the framework itself in a named scope if those apps render inside the host.
Decision 4: decide where strictness lives
Out of the box, the Orchestrator is forgiving: problems are worked around, not reported. Strict mode turns them into errors. Here are the settings that matter most (configuration docs):
| Setting | Default behavior | When turned on |
|---|---|---|
strictExternalCompatibility |
Incompatible remote gets its own copy | Startup fails with an NFError
|
strictRemoteEntry |
A remote that can't be loaded is skipped, with a warning | Startup fails with an NFError
|
strict: true |
– | Turns on every strict setting at once |
I ran both cases on 4.8.0. A remote requiring ^20.1.5 against a host on 20.1.4, with strictExternalCompatibility:
NFError: Could not determine shared externals in scope __GLOBAL__.
[mfe2][@angular/[email protected]] Is not compatible with requiredRange '^20.1.5' of shared @angular/[email protected].
Without strict mode, the same remote quietly got its own Angular, and nothing at all was logged, even with warnings switched on. A remote whose remoteEntry.json returned 404 was skipped with a single warning, Could not fetch remote 'mfe9'. skipping init., which you only see with logLevel: 'warn'.
For remotes added later with initRemoteEntry(), strict mode refuses an incompatible remote, but the call only fails if strictRemoteEntry is on too (source). Otherwise it logs "continuing anyway" and your later loadRemoteModule() call fails instead. Turn both on together.
My recommendation: be strict where failing is cheap, and forgiving where it's expensive.
-
Development and staging:
strict: true. A mismatch should stop the build pipeline's smoke tests, not reach users. - Production: not strict, so one broken remote can't take down the whole app, but with warnings switched on.
initFederation('federation.manifest.json', {
strict: !environment.production,
logLevel: 'warn',
});
The logLevel: 'warn' line matters. The Orchestrator's default log level is error, so every drift warning in this article, including the pooling message that names the gap, is hidden unless you lower it. The Angular adapter doesn't change that default.
The v3 Classic Runtime had none of these switches. A remote that failed to load was logged to the console, and version problems were never reported at all.
A checklist for your decision record
- [ ] Every shared library has a category: framework, stateful, utility or internal.
- [ ] Framework packages use
keepAlland onepoollabel, and design systems built on them carry the same label. - [ ] Utilities that don't hold state use
singleton: false. - [ ]
strictVersion: falseis only used where a wrong version is harmless. - [ ] Named share scopes are documented: who is in each group, and why.
- [ ] Staging runs with
strict: true, and production withlogLevel: 'warn'. - [ ] Framework updates are grouped into one pull request and rolled out host first.
Part 7 puts all of this into practice: a step-by-step plan to move from v3 to v4.
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
- Version Drift and Resolution Strategy: keeping independently deployed apps in sync (this article)
- 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
- Dependency pooling
- Orchestrator: version resolver, including share scopes and the strict scope
- Orchestrator: configuration, including strict mode and logging
- Core: sharing, including the
poolandshareScopeoptions
From the maintainers
- Orchestrator 4.8.0: one build per package (announcement)
- Auke van Oostenbrugge, Tweaking native-federation, Part II: Sharing externals
Next in this series: **v3 vs. v4, a detailed comparison and a practical migration plan.


Top comments (0)