Micro frontends sound like an architecture pattern. They're actually a deployment pattern that happens to have UI consequences. The question they answer isn't "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?"
The wrong reason to adopt them: "our app is big." (Big apps want an Nx monorepo with feature libraries.) The right reason: independent deployments, different release cadences, separate teams that don't want to coordinate every push to production.
This is the production playbook, built on the same real app throughout: Mattrx, a multi-tenant marketing-analytics SaaS on Angular 19, where the partner-widgets team carved out into a separate remote in early 2026. Real numbers from that split are throughout.
The one idea
Independent deployment is the whole point. The shell's HTML never re-bundles the remote — it fetches it fresh at runtime (or from cache), evaluates it in place, and reuses the singleton shared deps. Update the remote on its CDN → the next navigation picks it up. The shell does not redeploy when the remote ships.
Three concepts:
- Host (shell) — loads first. Owns routing, auth, layout, in-host features. Knows the URLs of its remotes, not their code.
- Remote — a separately-built, separately-deployed Angular sub-app that exposes lazy-loadable components/routes via a manifest.
- Contract — the named exports each remote exposes. The host imports them by name; the remote promises to keep them stable.
Two paths: Webpack MF vs Native Federation
| Webpack MF | Native Federation | |
|---|---|---|
| Basis | Webpack 5 ModuleFederationPlugin | Import Maps + ESM |
| Build | webpack (slower) | esbuild (faster) |
| Config | custom webpack | default Angular builder |
| New Angular 19 project | only with webpack expertise | Recommended |
| Already on webpack | stay | migrate later if convenient |
For Mattrx we chose Native Federation for the two new remotes. Honest reason: it didn't require swapping our build system back to webpack.
Native Federation — the modern setup
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
The shell points at remotes via a small JSON manifest (.json, not .js):
// apps/shell/src/assets/manifest.json
{
"partner-widgets": "https://partners.mattrx.io/remoteEntry.json",
"labs": "https://labs.mattrx.io/remoteEntry.json"
}
A lazy route pulls the remote:
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) },
];
The remote declares what it exposes:
// apps/partner-widgets/federation.config.js
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' }) },
});
The day-2 reality (where the real engineering lives)
Immutable chunks, mutable manifest. remoteEntry.json changes every deploy → Cache-Control: no-cache. The content-hashed chunks it references → cache forever. Get this wrong and a user loads yesterday's manifest pointing at today's missing chunks.
Atomic deploys. Upload chunks first, verify they return 200 from the CDN, then flip the manifest pointer. If the manifest goes live before the chunks exist, anyone loading in that window crashes. We learned this the hard way — two broken minutes in production.
Shared dependency version drift — the #1 source of pain. If the shell is on @angular/core@19.1.3 and the remote was built against 19.0.7:
-
singleton: true, strictVersion: true— boot-time error. Loud, deterministic, right. Use this for Angular packages. -
strictVersion: false— uses whichever loaded first. Silent breakage if APIs drift. OK only for a backwards-compatible design system. -
singleton: false— two Angulars. Don't.
When the shell bumps an Angular minor, every remote bumps the same week — a "version-bump-only" PR per remote.
The exposes contract is a SemVer surface. The host imports './PartnerCampaignsWidget' by name — that string is a public contract. Add freely, coordinate on signature changes, deprecate-for-one-release before removing. Keep a literal EXPOSES.md per remote. Contract-test it in CI against the remote's staging URL, on every shell PR and every remote PR.
Rollback is flipping the manifest back:
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 ~30s. No build, no redeploy.
The production numbers (after carving out 2 remotes)
| Metric | Before | After |
|---|---|---|
| Partner team deploys/week | 1 | 15–40 (3–8/day) |
| Time to ship a partner hotfix | 25 min | 4 min |
| Cross-team coordination tickets/sprint | 9 | 1 |
| Prod incidents from cross-team merges/mo | 12 | 3 |
| Shell bundle (gzipped) | 290 KB | 290 KB (unchanged) |
| Cold-start LCP (first remote hit) | 1.5s | 1.7s (+200ms — honest cost) |
| Warm-start LCP (cached remote) | 1.5s | 1.4s |
| Rollback on a remote regression | 25 min | 45 seconds |
The LCP cost is real and we own it. Idle-prefetch the manifest after the shell loads and cold-start drops back to ~+40ms:
provideAppInitializer(async () => {
await new Promise<void>(r => requestIdleCallback(() => r()));
await prefetchRemote('partner-widgets');
}),
When NOT to use micro frontends
The most-skipped section of every MFE article. Most teams shouldn't. Don't reach for MFEs if you're under ~25 frontend engineers in 1–2 teams, if you want smaller bundles (that's lazy loading), if you want faster CI (nx affected is cheaper), or if your teams ship together anyway.
Do consider them if a team ships to a different surface (partner CDN, extension), needs a conflicting deploy cadence, embeds the same code in third-party hosts, or is an acquired team merging in without a rewrite. For Mattrx, exactly one of those was true — so we carved out one remote, not ten.
The right mental model
Micro frontends are an operational tool dressed in architecture clothes. The architecture exists so that the deployment property (independent ship) becomes possible. Without the deployment property, the architecture is overhead with no upside.
Three habits: adopt one remote at a time for one reason at a time; treat the exposes contract as a SemVer surface (document + test in CI); coordinate the boring stuff (Angular minor bumps, design-system versions, CDN caching).
The full guide has the complete Webpack MF setup too, the request-flow and deploy-flow diagrams, the embeddable-widget use case, the 8-week adoption path, and the honest "what we'd do again / what we'd skip":
Originally published on PrepStack.
Top comments (0)