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
| 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 |
@GeorgeKaplan_G
/in/andres-gesteira-26a95813
Lead Frontend Engineer @Virtamed Zürich
What shipped · What a specifier is · Who resolves it
The whole talk, in one line
import { Component } from '@angular/core';
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
| 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
ESM
import()are live. They are left out of the graph deliberately until runtime.FOUR kinds of specifier
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:
Import maps
<script type="importmap">
{ "imports": {
"@angular/core": "/shell/_angular_core.EaOYP5562Y.js",
"@angular/common": "/shell/_angular_common.Kj3n8vQ1cR.js"
} }
</script>
the other answer
// SystemJS example: JavaScript you shipped reads this
System.addImportMap({ imports: { '@angular/core': '/vendor/core.js' } })
System.import('/add/main.js')
when
| 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 |
Two layers · Where Angular actually happens
Layer one
| 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
The point that has to land
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.
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
Before we compare anything
rspack.config.ts and @rspack/cli, directly. No build tool in between.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.
What it is · What it is not · The five nouns
What it is
Module Federation describes how independently built bundles find and share code at runtime. That is the entire scope of it.
remoteEntry.shared.It is not a framework, not a folder structure, and not a deployment model.
What it is not
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
| 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
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.
Same source · Three pipelines · Measured
The variants are just the three builds
| 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
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
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
Figure · four stages, three builds
| Stage | 1 · Angular CLI | 2 · Rspack | 3 · Vite |
|---|---|---|---|
| Compile | ngtsc runs AOT inside the bundler — an esbuild plugin, a webpack loader, a Vite plugin | ||
| Ship | remoteEntry.json plus plain ESM, bare specifiers kept | mf-manifest.json plus a remoteEntry.js container | |
| Boot | Fetch every remote's metadata, agree versions, write an import map | Record remote URLs only. Nothing is fetched until something asks | |
| Open /catalog | A real import() of the remote's Routes.js | import() the container → init(shareScope) → get('./Routes') | |
| Inside the route | loadComponent() is a plain dynamic import inside the remote — federation is not involved in this second lazy level | ||
What is running right now
| Application | Role | 1 · CLI | 2 · Rspack | 3 · Vite |
|---|---|---|---|---|
shell | host | 4200 | 4210 | 5173 |
catalog | page remote | 4201 | 4211 | 5174 |
orders | page remote | 4202 | 4212 | 5175 |
top-lots | widget MFE | 4203 | 4213 | 5176 |
roast-queue | widget MFE | 4204 | 4214 | 5177 |
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
| Dimension | 1 · Angular CLI | 2 · Rspack | 3 · Vite |
|---|---|---|---|
| Cold build, 5 apps | 8.4 s | 10.3 s | 8.1 s |
| Incremental rebuild | ~90 ms | ~80 ms | HMR, opt-in |
| Home page transferred, raw | 915 kB / 36 req | 268 kB / 26 req | 791 kB / 26 req |
Shell index.html eager refs | 23.6 kB gz | 32.8 kB gz | 1.5 kB gz |
Total JS on disk, top-lots | 766 kB | 752 kB | 674 kB |
| Lines of build config | 156 | 340 | 382 |
| Unused shares pruned | automatic | automatic | manual |
import.meta.url survives | yes | no | yes |
| Angular version ceiling | tracks Angular | adapter, <23 | none, ^18–^22 |
ng update, ng test, budgets | first-party | re-solve | re-solve |
MAKING CHOICES 1
MAKING CHOICES 2
https://github.com/kaplan81/re-imaginining-ng-module-deferation
Try Devin DeepWiki on it
For having me