Micro Frontends demo beautifully. Two remotes, a shell, a shared header — everything loads, everyone claps.
Then you ship to production, and a different category of problems appears. Problems that never show up in tutorials, never fail in local dev, and never get caught in code review — because they're not code problems, they're architecture problems.
I've spent the last couple of years building and maintaining Angular Micro Frontends (Native Federation, Nx, Angular 17→21) on a large government platform. Here are the 5 mistakes I either made myself or watched teams make — and how to avoid each one.
1. Sharing dependencies without pinning strategy
The default instinct: share everything. Angular, RxJS, your UI library — mark it all as shared and move on.
// federation.config.js — looks harmless
shared: {
...shareAll({ singleton: true, strictVersion: true, requiredVersion: 'auto' }),
}
This works until two teams deploy remotes built against different minor versions of a shared singleton. With strictVersion: true, one remote refuses to load at runtime. With strictVersion: false, you silently get two copies of RxJS and your takeUntilDestroyed starts behaving strangely across boundaries.
The fix: treat shared singletons (Angular core, RxJS, your design system) as a contract, not a convenience. Pin exact versions in a single source of truth (root package.json in an Nx monorepo makes this trivial), and make version bumps a coordinated event across all remotes — not something each team does whenever.
Rule of thumb: if it holds state or uses DI, it must be a singleton with a pinned version. If it's a pure utility, don't share it at all — duplication is cheaper than a version conflict.
2. Letting remotes own their auth logic
Every remote implementing its own token handling feels "independent." In reality you get:
- 4 slightly different interceptors
- Token refresh races (two remotes refreshing the same token simultaneously, one invalidating the other)
- Logout that works in the shell but leaves a remote with a live token in memory
The fix: authentication is a shell concern. The shell owns the token lifecycle, exposes it through a shared singleton service (or a tiny shared lib in your monorepo), and remotes consume — never manage — auth state. One interceptor, one refresh flow, one logout.
// libs/shared/auth — the ONLY place tokens live
@Injectable({ providedIn: 'root' })
export class AuthTokenService {
private readonly token = signal<string | null>(null);
readonly token$ = this.token.asReadonly();
// refresh logic lives here, once
}
If this service isn't a shared singleton, each remote gets its own instance and the whole design collapses — which is exactly why mistake #1 and #2 usually appear together.
3. Routing as an afterthought
In the demo, the shell lazy-loads each remote on a route and everything is fine. In production you discover:
- Deep links into a remote 404 on refresh because the shell's router doesn't know the remote's child routes
- Two remotes both defining a
/settingsroute - Browser back button jumping between remotes in ways users don't expect
The fix: define a routing contract early. The shell owns top-level route prefixes (/billing/** belongs to the billing remote, period), remotes own everything under their prefix, and no remote ever registers a route outside its namespace. Enforce it with a naming convention and a code review checklist — it's a 10-minute agreement that saves weeks.
4. No fallback when a remote fails to load
Remotes are loaded over the network. Networks fail. A deploy can briefly serve a broken remoteEntry.json. If your shell does this:
loadRemoteModule('billing', './Module') // and nothing else
…then one broken remote takes down the route, and depending on where it sits, sometimes the whole app shell with it.
The fix: every loadRemoteModule call gets a catch with a real fallback — an error component, a retry, and monitoring:
{
path: 'billing',
loadChildren: () =>
loadRemoteModule('billing', './Module').catch(err => {
monitoring.captureRemoteLoadFailure('billing', err);
return import('./fallback/remote-unavailable.module');
}),
}
This sounds obvious. Almost nobody does it, because in local dev the remote always loads.
5. Micro frontends without micro deployments
The most expensive mistake isn't technical. Teams adopt MFE architecture, then deploy all remotes together, from one pipeline, on one release train.
Congratulations — you now have all the complexity of Micro Frontends with none of the benefit. The entire point is independent deployability.
The fix: each remote gets its own CI/CD pipeline (GitHub Actions + Nx affected commands make this clean — only rebuild what changed), its own versioning, and its own ability to ship on a Tuesday afternoon without asking anyone. If you can't deploy one remote alone, you don't have Micro Frontends; you have a distributed monolith with extra network requests.
The pattern behind all five
None of these are coding mistakes. They're decisions that need to be made before the first remote is written — dependency contracts, auth ownership, routing namespaces, failure handling, deployment boundaries.
That's the real cost of MFE: not writing the code, but knowing which decisions matter.
If you want a starting point where these decisions are already made — Native Federation setup, shared auth singleton with JWT, routing structure, failure fallbacks, and per-remote GitHub Actions pipelines, all wired in an Nx workspace on Angular 21 — I open-sourced a free starter:
👉 NgMFE Lite on Gumroad — free, MIT, includes Arabic/RTL support out of the box.
Questions about any of the five? Drop them in the comments — I answer all of them.
Top comments (0)