Re-imagining Module Federation in Angular

Beyond the default toolchain — one product, five applications, three build pipelines, running side by side.

Angular Zürich Meetup · Angular 22

https://slides.com/agesteira/re-imagining-module-federation-in-angular

Running order

Agenda

Section What you leave with
Modules A bare specifier has to be resolved by somebody
Anatomy of a build Two different layers, and neither one speaks Angular
Module Federation as a mental model What it is, and the things it is not
Three implementations Same source, three pipelines, measured
Demo Fifteen dev servers, three shells, one product

Andres Gesteira

@GeorgeKaplan_G

/in/andres-gesteira-26a95813

 

 

 

 

  • Born in Madrid
  • Angular since 2016
  • Cinema
  • Books
  • Refuses to use  any
  • Not a morning person

Lead Frontend Engineer @Virtamed Zürich

Modules

What shipped · What a specifier is · Who resolves it

The whole talk, in one line

import { Component } from '@angular/core';

Who turns that string into a file?

Every answer in this talk is an answer to that question. Bundlers answer it at build time. Import maps answer it in the browser. Module Federation answers it at runtime, with JavaScript you shipped.

One slide, then we move on

The formats that got us here

Format Where Why it existed Why it is gone
CommonJS 2009 Node require() is synchronous, fine on a filesystem No static graph — you cannot know the imports without running the code
AMD 2010 Browser Async loading, back when the browser offered none Callback-shaped, needed a loader on the page
UMD Both A wrapper that sniffs which of the two it landed in Nothing left to sniff
ESM 2015 / 2017 Both Standardised, static, native in the browser It is what ships

ESM

Relation to JS scopes

  • A scope is a region of code that own a set of a name. Value bindings are connected through "import" and "export" declarations.
  • Bindings are live. You import a view of a value, not a copy.
  • Static imports are resolved before a line executes. That is what makes tree shaking possible at all.
  • Dynamic imports are resolved in runtime and return a promise. Every lazy route you have written in Angular is this one expression.
  • In JS there are four scopes: global, function, block and module. There is 1 module scope per file.

ESM

Module resolution

  • Consists of turning a specifier into a location where we can fetch a module.  
  • The resolution graph is static. Any tool can read through it without running it.
  • Dynamic import()are live. They are left out of the graph deliberately until runtime.
  • Module Federation relies on dynamic imports. In the end modules are about code splitting.

FOUR kinds of specifier

One of these is a URL

import { thing } from './thing.js';            // relative — this is a URL
import { Component } from '@angular/core';   // bare — this is not

The browser can fetch the first one. It has no idea what to do with the second.

Specifiers come in kinds:

  • Relative URLs paths. ./util.js
  • Absolute URLs paths /lib/util.js
  • Full URL. https://cdn.example/x.js
  • Bare specifier. lodash, @scope/pkg

 

Import maps

A URL table the browser reads

<script type="importmap">
{ "imports": {
    "@angular/core":   "/shell/_angular_core.EaOYP5562Y.js",
    "@angular/common": "/shell/_angular_common.Kj3n8vQ1cR.js"
} }
</script>
  • The browser's own module loader consults it. No library, no shim, no runtime on the page.
  • Baseline in every browser since 2023. This is not a proposal.
  • Build 1  with Native Federation writes one of these at boot — after asking every remote what version of Angular it wants. That makes it bundler-agnostic.

the other answer

A loader you ship

// SystemJS example: JavaScript you shipped reads this
System.addImportMap({ imports: { '@angular/core': '/vendor/core.js' } })
System.import('/add/main.js')
  • Before 2023 this was the only way to resolve a bare specifier in a browser:  ship a module registry, a resolver and a fetcher, written in JavaScript.
  • Every webpack bundle carries a module registry and a require shim. You do not need to write it, you just use a bundler.

when

The three times

Resolved at Who answers You ship
Build time The bundler Files with no specifiers left.
Load time The browser's native loader An import map
Runtime A loader you shipped Code decides
     

Anatomy of a build

Two layers · Where Angular actually happens

Layer one

Build tools are not bundlers

Build tool Bundler underneath
Angular CLI esbuild — plus a separate Vite instance for the dev server
Rsbuild Rspack
Vite Rolldown. As of Vite 8, esbuild is gone from Vite's dependencies entirely

Build tool: a policy layer — config format, dev server, plugin API, defaults, the opinion about your project's shape.
Bundler: resolves the module graph, applies transforms, splits and emits chunks. No opinion about your project.

Layer two

Compilers are not bundlers either

  • The Angular compiler has three parts and none of them is a bundler.
  • TypeScript compiler. Parses TS files, resolves symbols, performs type check.
  • Template compiler. Parses HTML files, bindings and Angular specific syntax expressions e.g. @if.
  • Ivy renderer. Writes ɵɵdefineComponent and the instruction calls.
  • A compiler makes each module valid.
  • A bundler decides what the modules are and where they live.

The point that has to land

Neither build tools nor bundlers understand Angular

Oxc and SWC strip types. That is the whole of what they do with your TypeScript. Every Angular-specific thing — AOT, template type-checking, ɵcmp generation, the DI metadata — is ngtsc, and it is ngtsc in all three of these builds.

       .ts .html .scss   ONE PIPELINE  resolve   ngtsc AOT   emit   chunks THE ONLY THING THAT DIFFERS esbuild Rspack Rolldown 
The Angular compiler is not a step before the bundler. It runs inside it, as that bundler's own extension point — an esbuild plugin, a webpack loader, a Vite plugin. No intermediate folder of compiled JavaScript ever exists, which is why tsconfig and inline-style handling are plugin settings rather than a separate build step you can inspect.

From the people who own the compiler

We are actively experimenting with a few strategies for tsgo support in Angular. Our current strategy is a more hybrid approach — using oxc in Rust to replace our compiler frontend, and connecting that to our existing compiler backend in TS".

Alex Rickabaugh · @synalx

  • Read it carefully — 1. Not "Oxc replaces the Angular compiler". Oxc replaces the frontend — parsing and the AST layer. The Angular-specific codegen stays in TypeScript.
  • Read it carefully — 2. The driver is tsgo, the native TypeScript compiler. The post says nothing about Rspack, and nothing about new Angular builders.

Before we compare anything

The caveat in the experiment

  • Build 2 configures the bundler. rspack.config.ts and @rspack/cli, directly. No build tool in between.
  • Build 3 configures the build tool. vite.config.ts. It never touches Rolldown at all.

Rsbuild would have been the fair counterpart to Vite — same layer, same job. It is not here because @nx/angular-rsbuild peers @angular/common >=19 <21 and cannot build an Angular 22 workspace.

So the comparison that follows is honest about bundler plus federation runtime, and slightly apples-to-oranges about developer experience. That substitution is itself the first finding: the adapter, not Angular, sets the version ceiling.

Module Federation as a mental model

What it is · What it is not · The five nouns

What it is

A mental model, not an architecture

Module Federation describes how independently built bundles find and share code at runtime. That is the entire scope of it.

  • There is a local module (also known as host or shell) and a number of remote modules. The latter are the exposed entries.
  • Any module is subject to become a shell.
  • Any module can be either and app or a container component (widget). But they are all declared as apps.
  • There is a reference per module that contains all the entries. A URL you fetch at runtime — remoteEntry.
  • There is a contract that establishes the shared modules and dependenceies. A negotiated version table — shared.

It is not a framework, not a folder structure, and not a deployment model.

What it is not

Module Federation is not micro-frontends

  • MF has no opinion about UI composition. It hands you a module. What you do with it is not its concern.
  • It runs server-side too. Node, SSR, edge workers. Nothing about it requires a DOM.
  • You can build micro-frontends with no MF at all. Iframes, web components, server-side includes, or plain import() against a known URL.

The two get conflated constantly, and the cost is real: teams adopt a loading mechanism and expect an architecture to come with it.

The separate list

What micro-frontends actually require

Requirement In practice Does MF give it?
Independent deployment One team ships without the others rebuilding Yes
Runtime composition Different included pieces are combined at runtime Yes
Tech agnostic The include does not care about the framework or its version Yes and no (not guaranteed)
DI boundary Prevents injection leaking between the parts You write it
Style isolation The host's CSS reset never reaches the remote You write it
Fault isolation A dead remote degrades one slot, not the page You write it

MF gives you separate builds and runtime composition. The other rows are the architecture — in this repo, they are why a remote provides its own HttpClient and why every project owns a byte-identical copy of its design tokens.

An aside, deliberately — CUT 1

The web component we did not build

A textbook micro-frontend wraps each remote in a custom element. The shadow boundary gives you style encapsulation for free — and, more to the point, it is what lets two remotes run different Angular versions on one page. Everything in this talk shares exactly one Angular instance instead.

We deliberately do not demonstrate it, because we do not recommend it. Two framework copies is two of everything: two change-detection loops, two injector trees, no DI inheritance across the boundary, and a serialisation layer between components that used to just pass objects. It buys independent upgrade cadence. It costs the thing that makes this feel like one application.

Three implementations of the same product

Same source · Three pipelines · Measured

The variants are just the three builds

Three ways to answer the same question

Build How Who loads the module
1 · Native Federation
:4200

import { ... } from "@angular/core" - The name survives the build. An import map installed at boot says which URL that name means.

The browser
2 · Classic MF on Rspack
:4210
__webpack_require__(4821) - Names became numbers in a registry. One <script> boots that registry; nothing else is a module the browser knows about. Bundle JS, all of it
3 · Classic MF on Vite
:5173
import { ... } from "./chunk-abc.js" - Real ES modules, relative paths. Only the shared packages route through a generated wrapper that asks the share scope which copy to use. The browser, except the sharing

Same five applications, same components, same mock backends. The three differ in the bundler that produced them and in what does the looking-up at runtime.

Figure · what each remote ships

Data a host reads, or code a host runs

First federation.manifest.json is loaded. And then...

  1 · Angular CLI 2 · Rspack 3 · Vite
data remoteEntry.json mf-manifest.json mf-manifest.json
loader none — plain ESM Routes.js remoteEntry.js (code) remoteEntry.js (code)
chunks _angular_core.EaOYP5562Y.js 118.js 223.js 365.js catalog.routes-ClDiaDIZ.js
specifiers kept — still says from "@angular/core" gone — numeric ids in a registry relative paths between real ES modules

Every remote publishes its code plus something that advertises what it offers. The difference is whether that something is data or an executable container.

Figure · runtime

Who resolves the module

       BUILD 1 · THE BROWSER RESOLVES BUILDS 2 & 3 · A RUNTIME RESOLVES  Routes.js  ./Routes   import "@angular/core" asks the federation runtime  import map  share scope (contains loader) a URL table the browser reads a JS object the runtime reads    _angular_core.js already loaded · one copy  host's @angular/core already loaded · one copy 
The import map is installed once at boot, before Angular is imported — which is why main.ts and bootstrap.ts are split in all three builds. The share scope is filled when remoteEntry.js runs init() and the two sides agree a version. Neither path re-downloads Angular.

The five nouns

Where each word lives

HOST · THE SHELL federation.manifest.json a name → URL table. data, not code share scope one negotiated copy per package REMOTE · BUILT AND DEPLOYED ALONE remoteEntry the one URL the host is given exposes ./Routes · ./Widgets 1 · fetch, at boot 2 · declares its versions 3 · get('./Routes'), on first use 4 · the loaded module resolves @angular/core from the share scope
The host never holds a compiled reference to the remote — only a URL it reads from a file. That is what "independently deployable" reduces to: adding a remote is a data change, and the host's bundle does not know it happened.

Figure · four stages, three builds

The same journey, side by side

Stage1 · Angular CLI2 · Rspack3 · Vite
Compilengtsc runs AOT inside the bundler — an esbuild plugin, a webpack loader, a Vite plugin
ShipremoteEntry.json plus plain ESM, bare specifiers keptmf-manifest.json plus a remoteEntry.js container
BootFetch every remote's metadata, agree versions, write an import mapRecord remote URLs only. Nothing is fetched until something asks
Open /catalogA real import() of the remote's Routes.jsimport() the container → init(shareScope)get('./Routes')
Inside the routeloadComponent() is a plain dynamic import inside the remote — federation is not involved in this second lazy level

What is running right now

Three pipelines, no overlap

ApplicationRole1 · CLI2 · Rspack3 · Vite
shellhost420042105173
catalogpage remote420142115174
orderspage remote420242125175
top-lotswidget MFE420342135176
roast-queuewidget MFE420442145177

Two kinds of remote, and the difference is load-bearing. A page remote owns a URL subtree and exposes ./Routes. A widget microfrontend owns no URL at all — it exposes ./Widgets, has no router, and the shell mounts it into a slot on a page the shell owns.

Measured · five apps, production, cold cache, one machine

The numbers

Dimension1 · Angular CLI2 · Rspack3 · Vite
Cold build, 5 apps8.4 s10.3 s8.1 s
Incremental rebuild~90 ms~80 msHMR, opt-in
Home page transferred, raw915 kB / 36 req268 kB / 26 req791 kB / 26 req
Shell index.html eager refs23.6 kB gz32.8 kB gz1.5 kB gz
Total JS on disk, top-lots766 kB752 kB674 kB
Lines of build config156340382
Unused shares prunedautomaticautomaticmanual
import.meta.url survivesyesnoyes
Angular version ceilingtracks Angularadapter, <23none, ^18–^22
ng update, ng test, budgetsfirst-partyre-solvere-solve

MAKING CHOICES 1

Why Vite is probably not an option

  • There is no federation reason to use Vite, unless you are very interested in its ecosystem.
  • Chunk URL rewriting is not possible.
  • Unused shared modules not automatically pruned.
  • There is no real advantage over the Angular CLI capabilities.

MAKING CHOICES 2

Why Rspack could be an option

  • After init, remotes can be overwritten, but that is not recommended. You need to set `{ force: true }` in the registration. Not possible in Native Federation.
  • Rspack explicitly supports runtime plugins for extending federation behaviour.
  • Also has hooks around the federation lifecycle.
  • Chunk URL rewriting is possible, unlike in Native Federation.
  • The loader itself (remoteEntry.js) is replaceable.
  • It is not tied to web standards but in this case that also means it can also run in the backend.
  • Seamless integration with Zephyr Cloud.

Demo time

Fifteen dev servers, three shells, one product

https://github.com/kaplan81/re-imaginining-ng-module-deferation

Try Devin DeepWiki on it

Thank you

For having me

Made with Slides.com