Part 4 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.
The Classic Runtime is the browser runtime that shipped with Native Federation v3. Its package is @softarc/native-federation-runtime. It also had a short v4 line, up to version 4.1.2, before it was retired in favor of the Orchestrator (Classic Runtime docs).
Why look at a retired runtime? Because a lot of production apps still run on it, and you can't plan a move away from something you don't understand. Its behavior also explains many bugs teams have quietly learned to live with.
In Part 3, we saw what the build writes into remoteEntry.json. This article shows what the Classic Runtime does with that file in the browser, and what it ignores. Every claim links to the official docs or to the runtime's source code from its last release.
How it works, in four steps
In a v3 app, main.ts calls initFederation() before Angular starts. Then the runtime does four things (init-federation.ts):
- Load the manifest, the list of remote names and URLs.
-
Load the host's own
remoteEntry.json. The host's shared libraries go into the import map's globalimports, so the whole page can use them. -
Load every remote's
remoteEntry.json, in parallel. Each remote's exposed modules go into the globalimports. Each remote's shared libraries go into a scope: a part of the import map that only applies to files loaded from that remote's URL. -
Add the import map to the page as a
<script type="importmap-shim">tag.
Why importmap-shim? A browser only accepts one normal import map per page, and the runtime may need to add more later. So it uses the es-module-shims library, which accepts several maps and merges them (v3 import map docs).
After that, loadRemoteModule('mfe1', './Component') finds the right file and loads it.
What versions can it resolve? Only exact matches
When the host and a remote both use a library, do they share one copy? The Classic Runtime decides with one simple rule. It keeps a list of the libraries it has already loaded, keyed by name and exact version (externals.ts):
function getExternalKey(shared: SharedInfo) {
return `${shared.packageName}@${shared.version}`;
}
- Same exact key already there? The remote reuses that file. The library is shared.
- Anything else, even one patch version apart? The remote downloads its own copy.
The host always registers first. The v3 runtime docs put it plainly: it's "an exact match, not a semver range." In other words, the Classic Runtime resolves no version ranges at all.
Seeing it in the files
Let's follow one example from start to finish. A host and two remotes, each owned by a different team, share @angular/core and rxjs using the default config from Part 3: singleton: true, strictVersion: true and requiredVersion: 'auto'. File names are shortened.
Step 1: what each team declared in package.json
shell/package.json (the host):
"dependencies": { "@angular/core": "^20.1.0", "rxjs": "^7.8.0" }
mfe1/package.json:
"dependencies": { "@angular/core": "^20.1.0", "rxjs": "^7.8.0" }
mfe2/package.json:
"dependencies": { "@angular/core": "^20.1.0", "rxjs": "^7.8.0" }
All three are identical, so the teams look aligned. But ^20.1.0 is a range. What actually gets installed depends on when each team last ran npm install:
| App |
@angular/core installed |
rxjs installed |
|---|---|---|
| shell (host) | 20.1.4 | 7.8.2 |
| mfe1 | 20.1.4 | 7.8.2 |
| mfe2 | 20.1.5 (installed after 20.1.5 came out) | 7.8.2 |
Step 2: what each build wrote into remoteEntry.json
The build copies the package.json range into requiredVersion and records the installed version as version.
The host, served from http://localhost:4200/:
{
"name": "shell",
"exposes": [],
"shared": [
{ "packageName": "@angular/core", "outFileName": "angular-core-AAA.js",
"version": "20.1.4", "requiredVersion": "^20.1.0", "singleton": true, "strictVersion": true },
{ "packageName": "rxjs", "outFileName": "rxjs-BBB.js",
"version": "7.8.2", "requiredVersion": "^7.8.0", "singleton": true, "strictVersion": true }
]
}
mfe1, served from http://localhost:4201/:
{
"name": "mfe1",
"exposes": [{ "key": "./Component", "outFileName": "Component-CCC.js" }],
"shared": [
{ "packageName": "@angular/core", "outFileName": "angular-core-DDD.js",
"version": "20.1.4", "requiredVersion": "^20.1.0", "singleton": true, "strictVersion": true },
{ "packageName": "rxjs", "outFileName": "rxjs-EEE.js",
"version": "7.8.2", "requiredVersion": "^7.8.0", "singleton": true, "strictVersion": true }
]
}
mfe2, served from http://localhost:4202/:
{
"name": "mfe2",
"exposes": [{ "key": "./Component", "outFileName": "Component-FFF.js" }],
"shared": [
{ "packageName": "@angular/core", "outFileName": "angular-core-GGG.js",
"version": "20.1.5", "requiredVersion": "^20.1.0", "singleton": true, "strictVersion": true },
{ "packageName": "rxjs", "outFileName": "rxjs-HHH.js",
"version": "7.8.2", "requiredVersion": "^7.8.0", "singleton": true, "strictVersion": true }
]
}
requiredVersion is the same everywhere. Only version differs, and only for mfe2's Angular.
Step 3: the import map the runtime adds to the page
I ran the Classic Runtime's own initFederation() code against these three files. This is exactly what it produced:
{
"imports": {
"@angular/core": "./angular-core-AAA.js",
"rxjs": "./rxjs-BBB.js",
"mfe1/Component": "http://localhost:4201/Component-CCC.js",
"mfe2/Component": "http://localhost:4202/Component-FFF.js"
},
"scopes": {
"http://localhost:4201/": {
"@angular/core": "./angular-core-AAA.js",
"rxjs": "./rxjs-BBB.js"
},
"http://localhost:4202/": {
"@angular/core": "http://localhost:4202/angular-core-GGG.js",
"rxjs": "./rxjs-BBB.js"
}
}
}
How it all connects
-
importsholds the host's libraries and every remote's exposed modules.mfe1/Componentis the nameloadRemoteModule('mfe1', './Component')looks up. -
mfe1's scope points at the host's files. Both its versions matched exactly, so it shares Angular and RxJS. Its own copies are never downloaded. -
mfe2's scope is mixed. RxJS 7.8.2 matched, so it's shared. Angular 20.1.5 didn't match 20.1.4, somfe2loads its own copy of Angular, even though^20.1.0allows both. -
singletonandstrictVersionleft no trace, even though every file set them totrue.
You can check this in your own app. In DevTools, find the importmap-shim script in the page's <head> and compare its scopes with the remoteEntry.json files in the network tab.
Your lockfile decides, not your package.json
Look at what happened to mfe2. The team changed no code and no config. They simply installed later, and production got a second copy of Angular. The same happens when a CI pipeline or a dependency bot updates a lockfile. Under the Classic Runtime, the installed version decides what gets shared.
Scale this up and it gets worse. With one host and six remotes spread over a few patch versions, a page can easily end up with four copies of Angular:
Remotes the host doesn't know up front
Both setups from Part 3 work the same way here. Remote URLs written in main.ts, or a federation.manifest.json fetched at startup: either way, the list is known before the app starts, and the four steps run as described.
For a remote found later, such as a plugin a user turns on, the only option is to pass its remoteEntry.json URL to loadRemoteModule():
await loadRemoteModule({
remoteEntry: 'http://localhost:4203/remoteEntry.json',
exposedModule: './Widget',
});
I tested this with a plugin remote whose lockfile installed Angular 20.1.6. The runtime fetched its remoteEntry.json, applied the same exact-match rule, and added a second import map (load-remote-module.ts):
{
"imports": { "plugin/Widget": "http://localhost:4203/Widget-JJJ.js" },
"scopes": {
"http://localhost:4203/": {
"@angular/core": "http://localhost:4203/angular-core-KKK.js",
"rxjs": "./rxjs-BBB.js"
}
}
}
Same rule, same result: RxJS is shared, Angular is not. The first map is never changed. Two limits are worth knowing:
- It's a one-off, tied to loading a module. You can't register a remote in advance.
-
The short form
loadRemoteModule('plugin', './Widget')fails until the remote has been registered this way.
The docs list this as "No dynamic init" (Classic Runtime docs). The Orchestrator adds initRemoteEntry() for it, covered in Part 5.
Your federation.config settings don't apply here
When building the import map, the Classic Runtime reads only three fields of each shared library: packageName, version and outFileName. You can see this in init-federation.ts. I searched every release I could find, from 16.3 to the last one, and the other settings appear only in type definitions and in get-shared.ts, a helper for working alongside webpack.
So under the Classic Runtime:
-
singleton: truedoesn't stop a second copy of Angular. -
strictVersion: truedoesn't raise an error. -
requiredVersion: '^20.1.0'doesn't let 20.1.5 share with 20.1.4.
If you've seen strictVersion errors before, they came from somewhere else. webpack Module Federation throws "Unsatisfied version … of shared singleton module …" when strictVersion is on. The Orchestrator can throw an NFError in strict mode.
But ignored doesn't mean wasted. The build still writes these settings into remoteEntry.json, and the Orchestrator acts on them. Switch runtimes, and they start working without rebuilding a single remote.
Why duplicate copies hurt
A second copy of a small utility just costs download time. For anything that holds state, it causes real bugs:
-
Angular expects to be alone on the page. Two copies break dependency injection, often with errors like
NG0203. - Stateful libraries, like a store or an auth client, end up with two separate instances. The host logs the user in, and the remote never finds out.
These bugs are hard to trace, because each app works fine on its own.
What it got right
To be fair, the Classic Runtime got a lot right:
- It's built on web standards, ES modules and import maps, which v4 kept.
- It's simple and predictable. One rule, easy to follow once you know it.
- Scopes protect the host. An incompatible remote gets its own copy instead of replacing the host's.
-
It has useful extras: a
fallbackoption inloadRemoteModule(), acacheTagoption for cache busting, and automatic reloads during development.
Where it hit its limit
The organizational cost. To share Angular, every team must ship the exact same patch version. Upgrading from 20.1.4 to 20.1.5 is no longer one team's decision. It has to be planned and released together across the host and every remote. That's lockstep releasing, which works against the main promise of micro frontends: teams that deploy independently.
The operational gaps, as listed in the Classic Runtime docs:
-
No caching between page loads. Every
remoteEntry.jsonis fetched again on every load. - Silent failures. A remote that fails at startup is only logged to the console, and the app starts without it.
- No share scopes to keep one team's libraries separate from another's.
Living with it until you migrate
If you're still on the Classic Runtime:
-
Find your duplicates by checking the
importmap-shimscripts in DevTools. - Pin exact versions of Angular and other stateful libraries, and update them in every repository together.
- Send browser console errors to your monitoring, so a remote that fails at startup raises an alert.
Then plan the move. The Orchestrator reads the same remoteEntry.json files, so remotes don't need to change. Part 7 covers the migration.
What Part 5 answers
The Classic Runtime leaves three open questions:
- How do you share a library across a range of versions?
- How do you make
singletonandstrictVersionactually do something? - How do you avoid fetching everything again on every page load?
The Orchestrator was built to answer all three. Part 5 opens it up.
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 (this article)
- The Orchestrator: how version resolution and caching work, in depth
- 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
- Classic Runtime (legacy), v4 docs
- Runtime overview, v3 docs
- The import map, v3 docs
- initFederation, v3 docs
- Orchestrator overview
- Migrating to v4
Articles by the maintainers
- Auke van Oostenbrugge, Tweaking native-federation, Part II: Sharing externals
Next in this series: **The Orchestrator, how version resolution and caching work, in depth.



Top comments (0)