Angular Micro Frontends with Module Federation in 2026 — Webpack MF, Native Federation, Independent Deployments (Real Project, Production Metrics)
Webpack Module Federation vs Native Federation, independent deployments, versioning shared deps — the Angular micro frontends playbook from Mattrx.
- Author
- Randhir Jassal
- Published
- Reading time
- 24 min read
- Views
- 7 views
Angular Micro Frontends with Module Federation in 2026 — Webpack MF, Native Federation, Independent Deployments (Real Project, Production Metrics)
Micro frontends sound like an architecture pattern. They're actually a deployment pattern that happens to have UI consequences. The question they answer is not "how do we structure code?" — it's "can team A ship without waiting for team B, even though both teams' code runs in the same browser tab?" If the answer is yes-but-painfully today, micro frontends are how you make it yes-and-routinely.
The wrong reason to adopt them: "our app is big." (Big apps want an Nx monorepo with feature libraries — see the Enterprise Angular Architecture guide.) The right reason: independent deployments, different release cadences, separate teams that don't want to coordinate every push to production.
This is the complete production playbook. We cover Webpack Module Federation (the original, mature, custom webpack config), Native Federation (the modern Angular-native approach using Import Maps + ES modules), the operational reality of independent deployments (versioning, shared deps, contract testing, rollbacks), and we do it with the same real app — Mattrx, a multi-tenant marketing analytics SaaS — where the partner widgets team carved out into a separate remote in early 2026. Real numbers from that split are throughout.
TL;DR
| Question | Answer |
|---|---|
| What problem do MFEs actually solve? | Independent deployments. Team A ships at 11:04 without team B touching anything. |
| What problem do they not solve? | "Big bundle" — use lazy loading + an Nx monorepo. "Slow CI" — nx affected is cheaper than MFE. |
| Webpack MF or Native Federation? | Native Federation for new Angular projects in 2026. Webpack MF if you already have it and it works. |
| One shared design system across remotes? | Yes — publish it as a versioned npm package; share it as a singleton. |
| Can the shell update without redeploying remotes? | Yes — that's the point. |
| Can a remote update without redeploying the shell? | Yes — that's the point. |
| Worst day-2 problem? | Shared dependency version drift. Two remotes shipped against different Angular minor versions; runtime breaks. |
| Right team size to consider MFEs? | ~25+ frontend engineers in 3+ teams, OR strong separate-deploy-cadence need (e.g., partner / labs / acquired team). |
Mattrx production wins from carving out the Partner Widgets remote (8-week project):
- Partner team deploy frequency: 1×/week (shipped with the monolith) → 3–8×/day (own remote)
- Time to ship a partner-specific hotfix: 25 min full pipeline → 4 min remote-only
- Cross-team coordination tickets per sprint: 9 → 1
- Production incidents caused by cross-team merges: 12/month → 3/month
- Initial shell bundle (gzipped): 290 KB (unchanged — same lazy strategy)
- Per-remote bundle (gzipped): 110–260 KB
- Cold-start LCP (first-visit, fetches 1 remote): 1.5s → 1.7s (+200ms — the honest cost)
- Warm-start LCP (cached remote): 1.5s → 1.4s (parallel fetches help)
The takeaway isn't that MFEs are faster. They're not, by ~200ms on cold start. They're a deployment unlock that you pay for in operational complexity and a small runtime cost. Adopt when the unlock matters.
1. Mattrx — the running example (and why MFE was the right call here)
Mattrx had been a single Angular app inside an Nx monorepo (4 apps: customer SaaS, admin, marketing, status). That's a great structure for one team across one product. Two things shifted in early 2026:
- A Partner Widgets team formed — 4 engineers building white-label dashboard widgets for partners that embed Mattrx data inside their products. The widgets ship to a different surface (partner CDN URLs), need their own release cadence, and have a different on-call rotation.
- A Labs / experimentation track started — an isolated environment for A/B tests that the core team didn't want gated on the main app's weekly release train.
For these specific concerns, the monorepo wasn't enough. We didn't need to re-organize the code — we needed independent deployments. That's a runtime concern, not a build-time concern. Module Federation answers it.
What we did NOT do: rewrite the whole app as MFEs. The customer SaaS app stayed monolithic. We carved out two remotes:
mattrx-shell (host) ← apps/customer + minimal routing
├─ feature/* (in-host) ← dashboard, campaigns, inbox, reports, settings
├─ partner-widgets (remote) ← owned by Partner team, deployed to its own CDN
└─ labs (remote) ← owned by Platform team, deploys 10×/day
Two remotes. One host. Everything else stayed in the monorepo. That's the realistic MFE adoption shape. Not all-or-nothing.
2. The mental model — host, remote, contract
BROWSER
┌──────────────────────────────────────────────────────────────┐
│ │
│ Mattrx Shell (HOST) ───────────────► Mattrx CDN │
│ • routing │
│ • auth │
│ • shell layout (header, sidebar) │
│ • in-host features (dashboard, campaigns, inbox, reports) │
│ │
│ When user navigates to /partners ─────► fetches at RUNTIME: │
│ │
│ ┌─────────────────────────┐ ┌─────────────────────┐ │
│ │ remoteEntry.js │ ◄──── │ Partner Widgets │ │
│ │ (manifest of exports) │ │ Remote CDN │ │
│ └─────────────────────────┘ └─────────────────────┘ │
│ │
│ Shared (singleton): @angular/core, @angular/common, │
│ @angular/router, RxJS │
│ │
└──────────────────────────────────────────────────────────────┘
▲ ▲
│ deploy │ deploy
│ │
┌───────────────┐ ┌───────────────┐
│ shell-team CI │ │ partner-team CI│
│ deploys shell │ │ deploys remote │
│ weekly │ │ multiple ×/day│
└───────────────┘ └───────────────┘
Three concepts to internalize:
- Host (a.k.a. shell) — the app that loads first. Owns routing, auth, layout, and the "in-host" features. Knows the URLs of its remotes but not their code.
- Remote — a separately-built, separately-deployed Angular sub-app. Exposes one or more lazy-loadable components or route tables via a manifest file (
remoteEntry.jsfor Webpack MF, the equivalent for Native Federation). - Contract — the named exports each remote exposes (
./Module→ a route table;./PartnerCampaignsWidget→ a standalone component). The host imports these by name; the remote promises to keep them stable.
Independent deployment falls out of the architecture: the shell's HTML never re-bundles the remote. It downloads it fresh each load (or from cache), evaluates it in-place, and reuses the singleton shared deps. Update the remote on its CDN → next page navigation picks it up.
3. Two paths: Webpack Module Federation vs Native Federation
There are two Angular toolchains for this in 2026. Both work. Pick based on which fits your situation.
3.1 Webpack Module Federation (@angular-architects/module-federation)
- The original. Battle-tested since 2020.
- Built on top of Webpack 5's
ModuleFederationPlugin. - Mature ecosystem, lots of community examples.
- Requires custom webpack config (the Angular CLI has esbuild as default now; you opt back into webpack for federation).
- Heavier setup but more knobs for advanced cases.
3.2 Native Federation (@angular-architects/native-federation)
- The modern path as of 2026. Built on Import Maps + ESM, not webpack.
- Works with the default Angular esbuild builder — no webpack config needed.
- Faster builds (esbuild is fast).
- Smaller setup. The "right thing" for new MFE projects.
The Angular team has been steering toward Native Federation for the same reasons the rest of the front-end ecosystem has moved to ESM + esbuild: simpler, faster, fewer moving parts.
3.3 The decision table
| Webpack MF | Native Federation | |
|---|---|---|
| New Angular 19 project | Consider this only if you have webpack expertise | Recommended |
| Existing webpack-based Angular | Stay on Webpack MF | Migrate later if/when convenient |
| Custom webpack plugins / loaders | Webpack MF | Native Federation can't run them |
| Build speed | webpack (slower) | esbuild (faster) |
| Setup complexity | Higher | Lower |
| Tooling: Angular CLI compatibility | Works with @angular-builders/custom-webpack | Works with default Angular builder |
| Production maturity | Very high | High — used in production widely since 2024 |
| Community examples | Tons | Growing |
For Mattrx we chose Native Federation for the two new remotes. Honest reason: it didn't require us to swap our build system back to webpack.
4. Webpack Module Federation — the original setup
Even if you pick Native Federation, knowing the Webpack MF shape helps you read 90% of MFE blog posts written between 2021 and 2025. It's also the right pick for established Angular apps already on the @angular-builders/custom-webpack toolchain.
4.1 Install + bootstrap
npm i -D @angular-architects/module-federation @angular-builders/custom-webpack
ng add @angular-architects/module-federation --project shell --type host --port 4200
ng add @angular-architects/module-federation --project partner-widgets --type remote --port 4201
Those commands rewrite angular.json to use a custom webpack builder, and emit webpack.config.js files for both projects.
4.2 The shell config (host)
// apps/shell/webpack.config.js
const { withModuleFederationPlugin } = require('@angular-architects/module-federation/webpack');
module.exports = withModuleFederationPlugin({
remotes: {
'partner-widgets': 'https://partners.mattrx.io/remoteEntry.js',
'labs': 'https://labs.mattrx.io/remoteEntry.js',
},
// Shared singletons — versions MUST match at runtime
shared: {
'@angular/core': { singleton: true, strictVersion: true, requiredVersion: 'auto' },
'@angular/common': { singleton: true, strictVersion: true, requiredVersion: 'auto' },
'@angular/router': { singleton: true, strictVersion: true, requiredVersion: 'auto' },
'@angular/common/http': { singleton: true, strictVersion: true, requiredVersion: 'auto' },
'rxjs': { singleton: true, strictVersion: true, requiredVersion: 'auto' },
'@mattrx/design-system': { singleton: true, strictVersion: false, requiredVersion: 'auto' },
},
});
4.3 The remote config
// apps/partner-widgets/webpack.config.js
const { withModuleFederationPlugin } = require('@angular-architects/module-federation/webpack');
module.exports = withModuleFederationPlugin({
name: 'partner-widgets',
filename: 'remoteEntry.js',
exposes: {
'./Module': './apps/partner-widgets/src/app/partner.routes.ts',
'./PartnerCampaignsWidget': './apps/partner-widgets/src/app/widgets/partner-campaigns.component.ts',
},
shared: {
'@angular/core': { singleton: true, strictVersion: true, requiredVersion: 'auto' },
'@angular/common': { singleton: true, strictVersion: true, requiredVersion: 'auto' },
'@angular/router': { singleton: true, strictVersion: true, requiredVersion: 'auto' },
'@angular/common/http': { singleton: true, strictVersion: true, requiredVersion: 'auto' },
'rxjs': { singleton: true, strictVersion: true, requiredVersion: 'auto' },
'@mattrx/design-system': { singleton: true, strictVersion: false, requiredVersion: 'auto' },
},
});
4.4 The shell loads the remote — lazy route
// apps/shell/src/app/app.routes.ts
import { Routes } from '@angular/router';
import { loadRemoteModule } from '@angular-architects/module-federation';
export const APP_ROUTES: Routes = [
// In-host (monolithic) feature
{ path: 'dashboard', loadChildren: () => import('@mattrx/features/dashboard').then(m => m.DASHBOARD_ROUTES) },
// Federated remote — fetched from a DIFFERENT origin at runtime
{
path: 'partners',
loadChildren: () =>
loadRemoteModule({
type: 'module',
remoteEntry: 'https://partners.mattrx.io/remoteEntry.js',
exposedModule: './Module',
}).then(m => m.PARTNER_ROUTES),
},
];
The runtime flow on the user clicking Partners in the navigation:
1. Angular Router sees /partners route
2. loadRemoteModule() resolves
3. Browser fetches https://partners.mattrx.io/remoteEntry.js (~12 KB)
4. remoteEntry exposes a `get('./Module')` and `init(sharedScope)`
5. Webpack MF runtime injects shared deps (Angular, RxJS) by reference
6. './Module' code (~210 KB) downloads
7. Router uses PARTNER_ROUTES, the partner component renders
The whole remote chunk is lazy-fetched on first navigation, then cached by the browser like any other chunk. Per-route latency on cold start: typically +150–250ms on top of "all monolithic" — the cost of an extra HTTP round trip and one cross-origin handshake.
5. Native Federation — the modern path
The Native Federation API mirrors Webpack MF conceptually but uses Import Maps under the hood. No custom webpack.
5.1 Install
npm i -D @angular-architects/native-federation
ng add @angular-architects/native-federation --project shell --type host --port 4200
ng add @angular-architects/native-federation --project partner-widgets --type remote --port 4201
5.2 The shell federation.config.js
// apps/shell/federation.config.js
const { withNativeFederation } = require('@angular-architects/native-federation/build');
module.exports = withNativeFederation({
shared: {
'@angular/core': { singleton: true, strictVersion: true },
'@angular/common': { singleton: true, strictVersion: true },
'@angular/router': { singleton: true, strictVersion: true },
'@angular/common/http': { singleton: true, strictVersion: true },
'rxjs': { singleton: true, strictVersion: true },
'@mattrx/design-system': { singleton: true, strictVersion: false },
},
});
5.3 The shell manifest.json (where remotes live)
// apps/shell/src/assets/manifest.json
{
"partner-widgets": "https://partners.mattrx.io/remoteEntry.json",
"labs": "https://labs.mattrx.io/remoteEntry.json"
}
Notice — .json, not .js. Native Federation uses a small JSON manifest that points at an Import Map. That manifest is what your shell fetches first when it needs to load a remote.
5.4 Bootstrap loads the manifest
// apps/shell/src/main.ts
import { initFederation } from '@angular-architects/native-federation';
initFederation('assets/manifest.json')
.catch(err => console.error(err))
.then(() => import('./bootstrap'))
.catch(err => console.error(err));
// apps/shell/src/bootstrap.ts
import { bootstrapApplication } from '@angular/platform-browser';
import { AppComponent } from './app/app.component';
import { appConfig } from './app/app.config';
bootstrapApplication(AppComponent, appConfig);
5.5 Load a remote route
// apps/shell/src/app/app.routes.ts
import { Routes } from '@angular/router';
import { loadRemoteModule } from '@angular-architects/native-federation';
export const APP_ROUTES: Routes = [
{ path: 'dashboard', loadChildren: () => import('@mattrx/features/dashboard').then(m => m.DASHBOARD_ROUTES) },
{
path: 'partners',
loadChildren: () =>
loadRemoteModule('partner-widgets', './Module').then(m => m.PARTNER_ROUTES),
},
{
path: 'labs',
loadChildren: () =>
loadRemoteModule('labs', './Module').then(m => m.LABS_ROUTES),
},
];
Cleaner. Less ceremony. Same independent-deployment guarantee.
5.6 The remote's federation.config.js
// apps/partner-widgets/federation.config.js
const { withNativeFederation, shareAll } = require('@angular-architects/native-federation/build');
module.exports = withNativeFederation({
name: 'partner-widgets',
exposes: {
'./Module': './apps/partner-widgets/src/app/partner.routes.ts',
'./PartnerCampaignsWidget': './apps/partner-widgets/src/app/widgets/partner-campaigns.component.ts',
},
shared: {
...shareAll({ singleton: true, strictVersion: true, requiredVersion: 'auto' }),
},
});
shareAll(...) is a convenience: declare every package in your package.json as a singleton with strict version. Useful for getting started; tighten to explicit lists as you mature.
5.7 Building + serving
# Build the remote — emits remoteEntry.json + the federated chunks
npx nx build partner-widgets
# Serve standalone (dev)
npx nx serve partner-widgets --port=4201
# Serve the shell, which will fetch the dev remote at http://localhost:4201
npx nx serve shell --port=4200
In production, the remote is uploaded to https://partners.mattrx.io/; the shell is uploaded to https://app.mattrx.io/. Cross-origin works as long as the remote's CORS lets the shell's origin fetch it.
6. Independent deployments — the operational reality
This is where the interesting engineering lives. The architecture is the easy part.
6.1 The deployment pipelines
SHELL pipeline (apps/shell)
├── on push to main
├── nx build shell
├── upload to app.mattrx.io (CDN, immutable hashes)
├── update root index.html pointer
└── invalidate CDN cache for index.html only
PARTNER-WIDGETS pipeline (apps/partner-widgets)
├── on push to apps/partner-widgets/**
├── nx build partner-widgets
├── upload to partners.mattrx.io (CDN, immutable hashes)
├── update partners.mattrx.io/remoteEntry.json pointer
└── invalidate CDN cache for remoteEntry.json only
The crucial bit: the shell does NOT redeploy when the partner remote ships. The shell's index.html still points at https://partners.mattrx.io/remoteEntry.json. The remote's pipeline updated the content of remoteEntry.json. The next page load fetches the new manifest → fetches the new federated chunks → renders the new code.
6.2 Immutable chunks, mutable manifest
partners.mattrx.io/
├── remoteEntry.json ← MUTABLE — pointer file. Cache-Control: no-cache.
├── partner-campaigns.AB12CD.js ← IMMUTABLE — hashed. Cache-Control: max-age=31536000.
├── partner-revenue-chart.EF34GH.js ← IMMUTABLE
└── ... every other chunk ...
Why this matters:
remoteEntry.jsonchanges on every deploy → must not be cached.- The chunks it references are content-hashed → cache them forever.
CloudFront / Cloudflare / Fastly all let you set per-path cache rules. Get this right or you'll have a confused user load yesterday's manifest pointing at today's missing chunks.
6.3 Atomic deploy → no broken intermediate state
The remote's CI must upload chunks first, then flip the manifest pointer. If remoteEntry.json gets updated before the chunks it references exist, anyone loading the page in that ~30-second window crashes on a missing chunk. The order is:
1. Upload partner-campaigns.AB12CD.js, partner-revenue-chart.EF34GH.js, etc.
2. (verify all chunks responding 200 from CDN)
3. PUT remoteEntry.json ← only NOW does the new version go live
4. Invalidate CDN cache for remoteEntry.json
We learned this the hard way in week two. Two broken minutes of production. Now the upload step is gated on a CDN ping.
6.4 Versioning and shared dependencies — the day-2 problem
Every shared dep has to agree at runtime. If the shell is on @angular/core@19.1.3 and the partner remote was built against @angular/core@19.0.7, what happens depends on the shared config:
singleton: true, strictVersion: true— boot-time error if versions don't match. Loud, deterministic, painful but right.singleton: true, strictVersion: false— uses whichever was loaded first. Silent breakage if the APIs drift.singleton: false— each side loads its own copy. Now you have two Angulars. Don't.
Use strictVersion: true for Angular packages. When the shell upgrades Angular minor, every remote has to upgrade in the same week. We coordinate this in a shared Slack channel and ship a "version-bump-only" PR per remote.
strictVersion: false is OK only for the design system, where minor changes are backwards-compatible by design.
6.5 The contract — what the remote promises to expose
The host imports a remote by name and exposed path:
loadRemoteModule('partner-widgets', './PartnerCampaignsWidget')
That means './PartnerCampaignsWidget' is a public contract. The remote can refactor everything inside, but if it renames or removes that export, the host crashes.
We treat the remote's exposes as semver-stable:
- New exposed module → patch-style change. Add freely.
- Changed signature of an exposed component's inputs → minor-style. Coordinate with the host team; ship the rename + the consumer fix together (or use a deprecation shim).
- Removed exposed module → breaking change. Announce, deprecate for one release, then remove.
For Mattrx we keep a literal EXPOSES.md in each remote's repo:
## partner-widgets — public contract
- `./Module` — the lazy-loaded route table for /partners
- `./PartnerCampaignsWidget` — standalone <ng-component> for partner-side embeds
Changes to this file = SemVer signal.
Not glamorous. Saves the entire team from "where did this break?" archeology.
6.6 Contract testing in CI
We added a contract test that the host runs in CI against each remote's staging URL:
// apps/shell/e2e/partner-contract.spec.ts
test('partner-widgets exposes the documented modules', async () => {
await page.goto('/partners');
await expect(page.getByTestId('partner-campaigns-widget')).toBeVisible();
// The remote also exposes its component class for direct embedding
const mod = await loadRemoteModule('partner-widgets', './PartnerCampaignsWidget');
expect(typeof mod.PartnerCampaignsWidget).toBe('function');
});
This runs against staging on every shell PR and every partner-widgets PR. A remote PR that would break the host fails the remote's CI — caught before it reaches prod.
6.7 Rollback — the same way you got there
Because the manifest is what changes, rolling back is "flip the manifest back":
# Roll partner-widgets back to yesterday's manifest
aws s3 cp s3://mattrx-partners/remoteEntry.PREVIOUS.json s3://mattrx-partners/remoteEntry.json
aws cloudfront create-invalidation --distribution-id ABC --paths "/remoteEntry.json"
# Live within ~30 seconds, next user navigation picks it up
No build, no redeploy, no shell change. Sub-minute rollback of a single remote is one of the best operational properties of the architecture.
7. A real partner widget — code start-to-finish
The Mattrx partner widget that partner sites embed inside their own dashboards.
7.1 The remote: standalone component + route
// apps/partner-widgets/src/app/widgets/partner-campaigns.component.ts
import { ChangeDetectionStrategy, Component, input, inject } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { HttpClient } from '@angular/common/http';
import { MxTable } from '@mattrx/design-system/table';
import { MxBadge } from '@mattrx/design-system/badge';
import { Campaign } from '@mattrx/shared-models'; // shared as a versioned package
@Component({
selector: 'mx-partner-campaigns-widget',
standalone: true,
imports: [MxTable, MxBadge],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<mx-table [rows]="campaigns()">
<ng-template #row let-c>
<td>{{ c.name }}</td>
<td>{{ c.spend | currency }}</td>
<td><mx-badge [variant]="c.status === 'active' ? 'green' : 'gray'">{{ c.status }}</mx-badge></td>
</ng-template>
</mx-table>
`,
})
export class PartnerCampaignsWidget {
partnerToken = input.required<string>();
private http = inject(HttpClient);
campaigns = toSignal(
this.http.get<Campaign[]>(`/api/partners/me/campaigns`, {
headers: { Authorization: `Bearer ${this.partnerToken()}` },
}),
{ initialValue: [] as Campaign[] },
);
}
// apps/partner-widgets/src/app/partner.routes.ts
import { Routes } from '@angular/router';
export const PARTNER_ROUTES: Routes = [
{ path: '', loadComponent: () => import('./pages/overview.component').then(m => m.PartnerOverview) },
{ path: 'campaigns', loadComponent: () => import('./pages/campaigns.component').then(m => m.PartnerCampaigns) },
{ path: 'revenue', loadComponent: () => import('./pages/revenue.component').then(m => m.PartnerRevenue) },
];
7.2 The host: lazy route into the remote
// apps/shell/src/app/app.routes.ts
import { Routes } from '@angular/router';
import { loadRemoteModule } from '@angular-architects/native-federation';
import { authGuard } from '@mattrx/core/auth';
export const APP_ROUTES: Routes = [
{ path: 'dashboard', loadChildren: () => import('@mattrx/features/dashboard').then(m => m.DASHBOARD_ROUTES), canMatch: [authGuard] },
{ path: 'campaigns', loadChildren: () => import('@mattrx/features/campaigns').then(m => m.CAMPAIGNS_ROUTES), canMatch: [authGuard] },
{ path: 'inbox', loadChildren: () => import('@mattrx/features/inbox').then(m => m.INBOX_ROUTES), canMatch: [authGuard] },
// The partner remote
{ path: 'partners',
canMatch: [authGuard],
loadChildren: () => loadRemoteModule('partner-widgets', './Module').then(m => m.PARTNER_ROUTES) },
// The labs remote
{ path: 'labs',
canMatch: [authGuard],
loadChildren: () => loadRemoteModule('labs', './Module').then(m => m.LABS_ROUTES) },
];
7.3 Embedding a single widget on a partner site (the bonus use case)
Beyond the in-host route, the partner team also lets partner sites embed the campaigns widget directly inside their own pages. Same federation contract, different consumer:
<!-- partner-site.com/index.html — NOT a Mattrx app -->
<script type="importmap">
{
"imports": {
"partner-widgets/": "https://partners.mattrx.io/"
}
}
</script>
<script type="module">
import { bootstrapApplication } from '@angular/platform-browser';
import { PartnerCampaignsWidget } from 'partner-widgets/PartnerCampaignsWidget';
bootstrapApplication(PartnerCampaignsWidget, {
providers: [provideHttpClient()],
});
</script>
<mx-partner-campaigns-widget partner-token="abc.def.ghi"></mx-partner-campaigns-widget>
This is the killer feature of Native Federation — it's just ES modules. Any host that understands import maps can consume your remote.
8. The Mattrx production numbers (after carving out 2 remotes)
| Metric | Before (single Angular app) | After (shell + 2 remotes) |
|---|---|---|
| Partner team deploys / week | 1 | 15–40 (3–8/day) |
| Labs team deploys / week | 0 (gated) | 40–60 |
| Time to ship a partner hotfix | 25 min (full pipeline) | 4 min |
| Cross-team coordination tickets / sprint | 9 | 1 |
| Production incidents from cross-team merges / month | 12 | 3 |
| Shell bundle (gzipped) | 290 KB | 290 KB (unchanged) |
| Partner remote (gzipped) | n/a | 220 KB |
| Labs remote (gzipped) | n/a | 110 KB |
| Cold-start LCP (mobile p75, first navigation that hits a remote) | 1.5s | 1.7s (+200ms — honest cost) |
| Warm-start LCP (remote in cache) | 1.5s | 1.4s (parallel chunk fetch helps) |
| Sentry "module not found" / "version mismatch" errors / month | n/a | 3 (early), 0 (after the version-bump-only PR cadence stabilised) |
| Rollback time on a remote-only regression | 25 min (full deploy) | 45 seconds (manifest flip + cache invalidation) |
These are wins on the dimensions MFEs are good at. The LCP cost is real and we own it. We mitigate it by prefetching the partner manifest in idle time after the shell loads:
// apps/shell/src/app/app.config.ts — in providers
import { initFederation, prefetchRemote } from '@angular-architects/native-federation';
// ...
provideAppInitializer(async () => {
// Once the shell is interactive, idle-prefetch remotes the user is likely to hit
await new Promise<void>(r => requestIdleCallback(() => r()));
await prefetchRemote('partner-widgets');
}),
After idle-prefetch, cold-start cost for partners drops back to ~+40ms.
9. When NOT to use Micro Frontends
This is the most-skipped section of every MFE article. The honest answer is most teams shouldn't.
9.1 Don't reach for MFEs if…
- You're under ~25 frontend engineers in 1–2 teams. Nx + feature libraries does everything you want with fewer moving parts.
- You want smaller bundles. MFEs split shipping; they don't shrink it. Lazy loading + standalone components does.
- You want faster CI.
nx affectedis cheaper than splitting into remotes. - Your teams ship together anyway. If you coordinate releases, MFEs are pure overhead.
- You don't have a separate-deploy-cadence need. Tomorrow's "we can ship the partner thing on its own" is yesterday's "we should ship the whole app together for stability."
- You don't want to operate two CDNs and manage cross-origin auth. This is real, ongoing work.
9.2 Do consider MFEs if…
- A team owns code that ships to a different surface (different URL, different on-call, different partners).
- A team needs to deploy on a different cadence that conflicts with the main app's release train.
- You're embedding the same code in third-party hosts (partner sites, browser extensions, embeddable widgets).
- An acquired team needs to merge into your front-end without rewriting overnight.
- Different teams ship in different tech stacks and you can't unify them (we don't recommend Angular-and-React MFEs unless you have to — but Module Federation supports it).
For Mattrx, exactly one of those was true: the partner widgets team. That's why we carved out one remote, not 10. Match the architecture to the actual problem.
10. Diagrams — the architecture and the request flow
10.1 Architecture overview
CDN-served, independently deployed
┌─────────────────────────────────────────────┐
│ │
▼ ▼
┌───────────────┐ ┌──────────────┐
│ app.mattrx.io │ (Shell — host) │ partners.mattrx.io│
│ index.html │ │ remoteEntry.json│
│ main.js │ │ chunks/* │
└───────┬───────┘ └──────────────────┘
│ At runtime, /partners route ─────────────┘
│ triggers manifest fetch + chunk load
▼
┌──────────────────┐
│ Browser (single │
│ page lifetime) │
│ • One Angular │
│ instance │
│ • One Router │
│ • Singletons │
│ shared between │
│ shell+remotes │
└──────────────────┘
10.2 Request flow on first navigation to /partners
Time →
0ms 100ms 200ms 300ms 400ms 500ms 600ms 700ms
│ │ │ │ │ │ │ │
/partners click
│
▼
[router checks lazy load]
│
▼
FETCH partners.mattrx.io/remoteEntry.json ────►
◄──── (12 KB, ~40ms p50)
│
▼
[Import Map updated, host knows about exports]
│
▼
FETCH partner-Module.chunk.js ────►
◄──── (220 KB, ~150ms)
│
▼
[Routes registered, route activated]
│
▼
[Component renders]
│
Total time on a warm connection: ~190ms. ──────────────────────────────┘
With idle-prefetch enabled, this is paid out-of-band — perceived 0ms on click.
10.3 Deploy flow
Partner team: Mattrx CDN (partners.mattrx.io): User browser:
───────────── ──────────────────────────────── ─────────────
│
git push (apps/partner-widgets)
│
▼
CI builds remote chunks
│
▼
Upload chunks (immutable, hashed) ──────► chunks live, not yet referenced
│
▼
Verify all chunk URLs return 200
│
▼
PUT remoteEntry.json ───────────────────► manifest now points at new chunks
│ (next user load picks up new code)
▼
Invalidate CDN cache for remoteEntry.json
│
User navigates to /partners
▼
Fetches new remoteEntry.json
▼
Fetches new chunks
▼
Sees new version
Shell did not redeploy. Host CDN was not touched.
11. The Mattrx adoption path — what we'd do again, what we wouldn't
WEEK 1 — Decide what to carve out
├── Audit which team / deploy-cadence problem is actually painful
├── Pick ONE candidate remote (smallest blast radius)
├── Confirm it has a clear public contract (a route + maybe a component)
└── If you can't name 3 things the host imports from it, it's not a remote yet
WEEK 2 — Tooling
├── Pick Native Federation (default) or Webpack MF (if you're already there)
├── Set up the shell with empty remotes map
├── Generate the first remote, point shell at it locally
└── Verify a lazy /<remote> route renders in dev
WEEK 3 — Move code into the remote
├── Move the feature library into apps/<remote>
├── Update imports — the remote depends on @mattrx/shared/*, not @mattrx/features/*
├── Add the `exposes` contract
└── Lock down strictVersion: true for Angular packages
WEEK 4 — Deploy story
├── Stand up a separate CDN bucket / origin for the remote
├── Wire the remote's CI to deploy: upload chunks, then flip manifest
├── Configure caching: chunks immutable, manifest no-cache
└── Test the cold-start path on slow 4G
WEEK 5 — Contract tests + observability
├── Add Playwright contract test in the host CI
├── Run the host CI against the remote's staging deployment per PR
├── Add Sentry tags so remote-origin errors are obvious
└── Document the EXPOSES contract for the remote team
WEEK 6 — Operational drills
├── Practice a remote-only rollback (manifest revert + CDN invalidate)
├── Practice a coordinated Angular minor bump
└── Document on-call: shell team gets shell errors; remote team gets remote errors
WEEK 7–8 — Iterate
├── Prefetch remotes the user is likely to hit (during idle)
├── If a second team needs an independent cadence: repeat for them
└── Don't carve more than your needs justify
What we'd do again: carving out one team that needed it, not the whole app.
What we'd skip: the LCP-fix sprint in week 6. We tried inlining the remote's first chunk into the shell to hide the +200ms cold cost. It worked, but it gave back the deploy-independence we'd just gained (the inlined hash had to match what the remote shipped). Use prefetch instead.
12. Honest stuff
- MFEs solve deployment problems, not architecture problems. Re-read that twice before adopting.
- Versioning shared deps is the #1 source of production pain.
strictVersion: truefor Angular packages + a coordinated upgrade discipline is non-negotiable. - Native Federation is the better default in 2026. Webpack MF is still fine if you're already on it.
- One remote that needs to exist is better than three for the principle of it. Adopt incrementally.
- Independent deploys mean independent on-calls. Don't carve out a remote without committing to operating it.
- Cold-start cost is real (~+200ms per first remote hit) and prefetch is mandatory. Anyone telling you MFEs are "free" performance-wise hasn't measured.
- Contract tests in CI replace 95% of "we shipped a breaking change to the host" panic. Set them up before you grow the second remote.
- You can mix-and-match. Mattrx is a shell + in-host features + two remotes. That's allowed and often correct.
13. The mental checklist
Before adopting MFEs:
- Is there a team whose deploy cadence is materially different from the main app's?
- Does the candidate remote have a clear, small public contract?
- Are we willing to operate a second CDN origin and own its caching policy?
- Are we willing to enforce
strictVersion: trueon Angular packages across teams? - Do we have contract tests in CI before the second remote ships?
- Can we accept a ~200ms cold-start LCP cost (mitigated by prefetch)?
- Do we have a documented rollback runbook (manifest flip + cache invalidate)?
If you can't tick most of those, stay on Nx + feature libraries until you can. That's not a defeat — it's the right architecture for your stage.
14. Closing — the right mental model
Micro frontends are an operational tool dressed in architecture clothes. The architecture (host + remotes + shared singletons) exists so that the deployment property (independent ship) becomes possible. Without the deployment property, the architecture is overhead with no upside.
Three habits that keep MFEs healthy long-term:
- Adopt one remote at a time, for one reason at a time. "We carved out the partner widget team because they needed to ship without us" is a sentence. "We're going micro frontends" is a slogan.
- Treat the
exposescontract as a SemVer surface. Document it. Test it in CI. Deprecate before removing. - Coordinate the boring stuff. Angular minor bumps. Shared design system versions. CDN caching. Boring done well is what makes MFEs survive year two.
Apply that, and the next time someone says "should we go micro frontends?" you'll have a real answer — for this specific situation, yes/no, here's why — instead of a vibe.
Further reading
- Native Federation docs — the modern path.
- Module Federation Examples — the canonical examples (mostly React, but the shape transfers).
- Manfred Steyer — Strategic Design for Angular Architectures — the team behind both Federation libraries; their blog is the best ongoing source.
- Web.dev — Import Maps — the browser standard Native Federation sits on top of.
- Cam Jackson — Micro Frontends — the canonical conceptual essay.
- PrepStack — Enterprise Angular Architecture (Nx + Core/Shared/Feature) — the monorepo alternative this guide compares against.
- PrepStack — Angular Performance Optimization — for the lazy-loading + bundle work that complements MFEs.
Considering Module Federation for a real deploy-cadence problem? Email randhir.jassal@gmail.com with the team structure and the specific reason you're thinking about MFEs — happy to give a yes/no based on whether the unlock matches the cost.
Get the next issue
A short, curated email with the newest posts and questions.