DEV Community

Sakthikumaran Navakumar
Sakthikumaran Navakumar

Posted on

The Classic (v3) Runtime - What It Got Right, and Where It Hit Its Limit

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):

The initFederation flow: load the manifest, load the host's remoteEntry.json and put its shared libraries in the global imports, load every remote's remoteEntry.json in parallel with exposed modules going into global imports and shared libraries into a scope for that remote's URL, then add one importmap-shim script to the page.

  1. Load the manifest, the list of remote names and URLs.
  2. Load the host's own remoteEntry.json. The host's shared libraries go into the import map's global imports, so the whole page can use them.
  3. Load every remote's remoteEntry.json, in parallel. Each remote's exposed modules go into the global imports. 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.
  4. 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}`;
}
Enter fullscreen mode Exit fullscreen mode

For each shared library in a remote, the runtime builds a key like @angular/core@20.1.5. If that exact key is already registered, the remote reuses the existing file. If not, the remote's own copy is downloaded. singleton, strictVersion and requiredVersion are not checked.

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

mfe1/package.json:

"dependencies": { "@angular/core": "^20.1.0", "rxjs": "^7.8.0" }
Enter fullscreen mode Exit fullscreen mode

mfe2/package.json:

"dependencies": { "@angular/core": "^20.1.0", "rxjs": "^7.8.0" }
Enter fullscreen mode Exit fullscreen mode

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

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

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

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

How it all connects

  • imports holds the host's libraries and every remote's exposed modules. mfe1/Component is the name loadRemoteModule('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, so mfe2 loads its own copy of Angular, even though ^20.1.0 allows both.
  • singleton and strictVersion left no trace, even though every file set them to true.

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:

One page, one host, six remotes, four copies of Angular. Only the host and mfe1, both on 20.1.4, share a copy. mfe2, mfe5 and mfe6 on 20.1.5 share a second copy, mfe3 on 20.2.0 has a third, and mfe4 on 19.2.0 has a fourth.

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

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

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: true doesn't stop a second copy of Angular.
  • strictVersion: true doesn'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 fallback option in loadRemoteModule(), a cacheTag option 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.json is 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-shim scripts 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:

  1. How do you share a library across a range of versions?
  2. How do you make singleton and strictVersion actually do something?
  3. 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

  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 (this article)
  5. The Orchestrator: how version resolution and caching work, in depth
  6. Version Drift and Resolution Strategy: keeping many independently deployed apps in sync
  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. Classic Runtime (legacy), v4 docs
  2. Runtime overview, v3 docs
  3. The import map, v3 docs
  4. initFederation, v3 docs
  5. Orchestrator overview
  6. Migrating to v4

Articles by the maintainers

  1. 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)