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.
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.tsand@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
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
Figure · four stages, three builds
The same journey, side by side
| 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
Three pipelines, no overlap
| 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
The numbers
| 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
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
Re-imagining Module Federation in Angular
By Andres Gesteira
Re-imagining Module Federation in Angular
- 148