DEV Community

Sakthikumaran Navakumar
Sakthikumaran Navakumar

Posted on

Version Drift and Resolution Strategy - Keeping Independently Deployed Micro Frontends in Sync

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: false only 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/router@22.1.0 really requires @angular/core@22.1.0 (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"
  }
}
Enter fullscreen mode Exit fullscreen mode

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"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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__').
Enter fullscreen mode Exit fullscreen mode

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.

mfe1's router was built for Angular 20.1.5 and the host ships core 20.1.4 only. With no pool label, mfe1 runs core 20.1.4 from the host's build with router 20.1.5 from its own build: a mix nobody built, with no warning. With pool angular, mfe1 runs its own complete family, core and router 20.1.5, plus a warning naming the gap. With pool and the host shipping the router, everyone runs core and router 20.1.4 from the host's build: one complete family.

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' }),
Enter fullscreen mode Exit fullscreen mode

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 angular pool. 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 billing and invoices each 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.

Two teams, two major versions of @acme/ui. With one global scope, orders and catalog share 3.2.0, while billing and invoices each load their own copy: three copies. With named scopes team-a and team-b, team-a shares 3.2.0 and team-b shares 2.5.0: two copies.

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/core@20.1.5] Is not compatible with requiredRange '^20.1.5' of shared @angular/core@20.1.4.
Enter fullscreen mode Exit fullscreen mode

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',
});
Enter fullscreen mode Exit fullscreen mode

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 keepAll and one pool label, and design systems built on them carry the same label.
  • [ ] Utilities that don't hold state use singleton: false.
  • [ ] strictVersion: false is 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 with logLevel: '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

  1. The Mental Model, Revisited: why Native Federation exists and what changed in v4
  2. Anatomy of the v4 Package Graph: Core, Adapters, Runtime and Orchestrator as separate layers
  3. Build-Time, End to End: from federation.config to remoteEntry.json
  4. The Classic (v3) Runtime: what it got right, and where it hit its limit
  5. The Orchestrator: how v4 decides what loads, and what changed in 4.8.0
  6. Version Drift and Resolution Strategy: keeping independently deployed apps in sync (this article)
  7. v3 vs. v4: a detailed comparison and a practical migration plan
  8. Reference Architecture: running Native Federation v4 in a regulated enterprise

Further reading

Official documentation

  1. Dependency pooling
  2. Orchestrator: version resolver, including share scopes and the strict scope
  3. Orchestrator: configuration, including strict mode and logging
  4. Core: sharing, including the pool and shareScope options

From the maintainers

  1. Orchestrator 4.8.0: one build per package (announcement)
  2. 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)