Micro frontends get pitched as the natural next step after microservices — split the UI the way you split the backend, ship teams independently, deploy without coordination. That pitch is half true. The other half is the part nobody puts in the conference talk: shared state headaches, duplicated bundles, version drift across teams, and a debugging story that gets meaningfully harder.
This article walks through the two dominant patterns for building Angular micro frontends — Single-SPA and Module Federation — with enough architectural detail to make a real decision, not just a demo that works on your laptop.
Before the how: should you even do this?
Micro frontends solve an organizational problem, not a technical one. The technical problem (one big Angular app is slow to build and risky to deploy) has cheaper fixes first: Nx monorepo with incremental builds, lazy-loaded standalone routes, esbuild-based builders. If a single team owns the app, none of those cheaper fixes are exhausted yet, and reaching for micro frontends first is usually premature.
Micro frontends earn their complexity when:
- Multiple autonomous teams ship to the same product on independent release cadences
- Different parts of the app genuinely need different tech stacks or major-version Angular/React/Vue coexistence
- A monolith's build/deploy time has become an organizational bottleneck, not just an annoyance
If none of those are true, skip this article's implementation section and go fix your build pipeline instead. Assume from here that the organizational case is real.
Two different philosophies, not two flavors of the same thing
It's tempting to treat Single-SPA and Module Federation as interchangeable "micro frontend tools." They're not — they solve different layers of the problem.
| Single-SPA | Webpack Module Federation | |
|---|---|---|
| What it actually is | A top-level router/orchestrator that mounts and unmounts independently-built apps | A build-time/runtime mechanism for one bundle to consume code from another bundle |
| Unit of composition | Whole applications (each with its own bootstrap lifecycle) | Individual modules/components, at any granularity |
| Framework agnosticism | Strong — mixes Angular, React, Vue, vanilla JS as siblings | Works across frameworks too, but composition is finer-grained and framework boundaries get blurrier |
| Routing ownership | Single-SPA owns top-level routing; each app owns its internal routing | No opinion on routing — you wire it yourself |
| Typical use case | "The dashboard is one app, billing is another, teams ship independently" | "This app needs a button-level component or a specific module from another team's build, sometimes even inside the same route" |
| Deployment model | Each app deployed independently, registered at a known URL/import map | Each "remote" deployed independently, exposed via a federation manifest |
| Maturity in Angular 20 workflow | Mature, but Angular Elements wrapping and zone coordination are manual work | Native Federation (via @angular-architects/native-federation) replaced Webpack-specific federation as the standard approach for esbuild-based Angular builds |
In practice, most real Angular micro frontend architectures at enterprise scale use both: Single-SPA (or a simpler custom shell) for top-level app orchestration, and Module Federation for sharing specific components or libraries across those apps without duplicating them in every bundle.
Architecture at a glance
The shell doesn't know or care what's inside Dashboard or Billing. Dashboard and Billing independently pull a shared component (say, a notification bell or an auth-status widget) via Module Federation instead of each bundling their own copy.
Setting up the shell with Single-SPA
A minimal root config for an Angular-based shell looks like this:
// root-config.ts
import { registerApplication, start } from 'single-spa';
registerApplication({
name: '@company/dashboard',
app: () => System.import('@company/dashboard'),
activeWhen: ['/dashboard'],
});
registerApplication({
name: '@company/billing',
app: () => System.import('@company/billing'),
activeWhen: ['/billing'],
});
start({
urlRerouteOnly: true,
});
Each micro frontend is bootstrapped as a Single-SPA "lifecycle" application — it exports bootstrap, mount, and unmount functions. For Angular, single-spa-angular wraps this for you:
// dashboard's main.single-spa.ts
import { singleSpaAngular, getSingleSpaExtraProviders } from 'single-spa-angular';
import { bootstrapApplication } from '@angular/platform-browser';
import { AppComponent } from './app/app.component';
import { appConfig } from './app/app.config';
const lifecycles = singleSpaAngular({
bootstrapFunction: (singleSpaProps) =>
bootstrapApplication(AppComponent, {
...appConfig,
providers: [...appConfig.providers, getSingleSpaExtraProviders()],
}),
template: '<app-root />',
Router: null, // let Angular's own router handle internal routes
NgZone: Zone.current.fork({ name: 'dashboard' }) as any,
});
export const { bootstrap, mount, unmount } = lifecycles;
Notice the NgZone fork. This is the detail that separates a demo from a production setup — more on it below.
Sharing code with Module Federation (Native Federation for Angular)
For Angular apps built with the modern esbuild-based builder, @angular-architects/native-federation is the current standard — it replaced raw Webpack Module Federation config for teams on Angular's default build pipeline.
Remote (exposing a shared library):
// federation.config.js (remote: shared-ui)
module.exports = withNativeFederation({
name: 'shared-ui',
exposes: {
'./NotificationBell': './src/app/notification-bell.component.ts',
},
shared: {
...shareAll({
singleton: true,
strictVersion: false,
requiredVersion: 'auto',
}),
},
});
Host (consuming it):
// federation.config.js (host: dashboard)
module.exports = withNativeFederation({
shared: {
...shareAll({ singleton: true, strictVersion: false, requiredVersion: 'auto' }),
},
});
// lazy-loading the remote component in a route
{
path: 'notifications',
loadComponent: () =>
loadRemoteModule({
remoteName: 'shared-ui',
exposedModule: './NotificationBell',
}).then((m) => m.NotificationBellComponent),
}
Because NotificationBellComponent is a standalone component, there's no NgModule ceremony on either side — this is one of the concrete reasons Angular 20+'s standalone-first posture matters for micro frontends specifically: fewer surfaces where two apps' module graphs can conflict.
Change detection implications — the part most tutorials skip
This is where micro frontends stop being a routing problem and become an Angular internals problem.
Zone.js collisions. By default, every Angular app monkey-patches the same global async APIs (setTimeout, addEventListener, Promise, XHR) through Zone.js. Load two independently-bootstrapped Angular apps on the same page without isolating their zones, and you get one of two failure modes: change detection storms (one app's async event triggers CD in both apps) or, worse, silent CD skips where updates in app B never render because zone context got lost crossing into app A's execution. The Zone.current.fork() call above isn't boilerplate — it's what keeps each MFE's change detection cycle scoped to itself.
Zoneless changes the calculus. Angular 18+'s zoneless change detection (stabilized further in 20) sidesteps the zone-collision problem entirely for apps that adopt it, because there's no shared monkey-patched global state to collide over — each app's signals and ChangeDetectorRef.markForCheck() calls are self-contained. If you're architecting a new micro frontend system today, defaulting every MFE to zoneless is one of the highest-leverage decisions you can make, precisely because it removes a whole category of cross-app bugs before they exist.
Singleton services aren't singletons anymore. providedIn: 'root' means root of that app's injector tree. Two federated apps each get their own instance of what looks like a shared service — including shared state services, auth token stores, and RxJS BehaviorSubject-based stores. If Dashboard and Billing both import an "auth state service" via Module Federation but each ends up with its own instance, they'll silently drift out of sync. The fix is either: promote true cross-cutting state to the shell and pass it down via custom events / a shared event bus, or explicitly share the service instance through Module Federation's shared config with singleton: true and verify at runtime that both apps resolved the same instance (log the object identity in dev, don't just assume the config worked).
Performance considerations
-
Duplicate bundle weight is the default failure mode, not an edge case. Without careful
sharedconfig, every MFE ships its own copy of Angular core, RxJS, and any common UI library — multiplying total payload by the number of MFEs on the page.shareAll({ singleton: true })mitigates this but only for packages that are actually declared shared and version-compatible; a silent version mismatch causes federation to fall back to bundling a second copy, and that fallback fails quietly unless you're watching bundle analyzer output. - Waterfall loading across remotes. A host loading a remote that itself loads another remote creates a request waterfall invisible in local dev (everything's on localhost, latency is near zero) but very visible in production over real network conditions. Model this with WebPageTest or Chrome DevTools network throttling before shipping, not after a complaint ticket.
- Version drift is a runtime bug, not a build error. Module Federation resolves shared dependency versions at runtime. Two teams shipping independently can produce a combination that was never tested together, and it'll only surface as a production error, not a build failure. This is the single biggest argument for contract testing between MFEs, not just unit tests within each one.
Common anti-patterns
- Micro frontends that share a database-level of coupling in the UI layer — if Billing can't render without deep knowledge of Dashboard's internal component state, you've built a distributed monolith with extra deployment steps, not an independent system.
- No shared design system, ad hoc CSS per MFE — leads to visual drift and specificity wars when MFEs are composed on the same page.
- Treating Module Federation as a dependency injection replacement — it's a code-loading mechanism, not an architecture. It won't stop teams from building tightly coupled contracts unless you deliberately design against it.
Interview questions this topic tends to produce
- How does Angular's Zone.js cause conflicts when multiple Angular apps run on the same page, and how would you isolate them?
- Walk through what happens if two federated remotes declare incompatible versions of a shared singleton service.
- Single-SPA vs Module Federation — when would you use one without the other?
- How does moving to zoneless change detection affect micro frontend architecture decisions?
- What's your strategy for testing integration between independently-deployed MFEs before they reach production?
Challenge exercise
Take a small Angular app with two standalone feature routes. Split it into a shell (Single-SPA) plus one Module Federation remote exposing a shared component with its own BehaviorSubject-backed state service. Verify — with actual console logging of instance identity, not assumption — whether the shell and the remote end up with one shared instance of that service or two. Then fix it so they share one, and write down exactly which config line made the difference.

Top comments (0)