Microfrontend architecture
Microfrontend architecture splits a frontend application into multiple independent web apps, each owned by a separate team, that load and compose into a single UI at runtime.
Theory
TL;DR
- Analogy: like Lego blocks - each team builds and ships their own block independently; the shell app snaps them together at runtime without rebuilding the whole set
- Main difference vs monolith: teams deploy separately, pick their own frameworks (React for team A, Vue for team B), and one team's broken CSS cannot take down another team's UI
- Module Federation (Webpack 5) is the dominant approach in production today; single-spa is the other popular choice
- Use if you have 50+ devs across multiple teams; skip it for teams under 20
- The real cost is infrastructure: every MF needs its own CI/CD, monitoring, and versioning
Quick example
Shell app using single-spa loads two microfrontends at runtime:
<!-- index.html - shell loads MF1 (React) and MF2 (Vue) -->
<!DOCTYPE html>
<html>
<head>
<script src="https://cdn.jsdelivr.net/npm/single-spa@latest/lib/system/single-spa.min.js"></script>
</head>
<body>
<div id="navbar"></div> <!-- React MF mounts here -->
<div id="dashboard"></div> <!-- Vue MF mounts here -->
<script>
// Register navbar MF (React, always active)
System.import('http://localhost:3001/navbar.js').then(navbar =>
singleSpa.registerApplication('navbar', navbar.default, () => true));
// Register dashboard MF (Vue, active only on /dash routes)
System.import('http://localhost:3002/dashboard.js').then(dashboard =>
singleSpa.registerApplication('dashboard', dashboard.default,
() => window.location.pathname.startsWith('/dash')));
singleSpa.start();
</script>
</body>
</html>The shell fetches each MF bundle from its own server. Navigate to /dash and single-spa mounts the Vue app in #dashboard. Navigate away and it unmounts. The React navbar stays up the whole time.
Key difference vs monolith
In a monolith, all teams share one repo, one build, and one deploy. Team A's bad CSS cascades into Team B's buttons. Team C's dependency upgrade breaks Team D's tests. Nobody ships on Friday. Microfrontends break that coupling at the infrastructure level: each app has its own bundle, its own release cadence, and its own container registry entry. Team A ships React 18 without waiting for Team B to finish their Angular 16 migration.
Integration approaches
There are four main approaches, each with different tradeoffs.
Run-time via Module Federation (Webpack 5) is the current production standard. The shell's webpack.config.js declares remote MFs by URL; each remote exposes components. Shared dependencies (React, react-dom) load once as singletons. Teams deploy independently and the shell picks up changes on next page load.
Run-time via single-spa is a framework-agnostic orchestrator. Each MF registers a lifecycle (bootstrap/mount/unmount) and an activity function that tells the orchestrator when to show it. Works well for heterogeneous stacks.
iframes give complete isolation and are simple to set up, but have terrible ergonomics. Cross-origin communication needs postMessage, routing is a mess, and accessibility suffers. Use only when you genuinely need full sandboxing, like embedding third-party content.
Build-time integration (npm packages) means MFs are published as packages and imported at build time. This loses independent deployment: updating one MF requires rebuilding and redeploying the host. Rarely what you actually want.
When to use
Use microfrontends when:
- You have 3+ teams that need to ship without coordinating releases
- Your app has clearly separated business domains (catalog, checkout, account)
- You are migrating a legacy monolith and want to replace one section at a time
- Teams genuinely disagree on tech stack and you can accept that complexity
Skip microfrontends when:
- The team is under 20 developers
- You are building an MVP or early-stage product
- The app is mostly static or content-heavy
- A well-structured monorepo with clear module boundaries would give you 80% of the independence at 20% of the cost
How the browser composes microfrontends
The browser fetches the shell HTML. SystemJS (or native ES modules) handles dynamic imports via System.import(). Each MF bundle registers a lifecycle with the orchestrator. On route change, the orchestrator checks window.location, calls mount() on active MFs and unmount() on inactive ones. CSS isolation comes from Shadow DOM (element.attachShadow({ mode: 'open' })) or scoped CSS Modules. Cross-MF communication goes through Custom Events on window or BroadcastChannel for cross-origin scenarios.
In practice, teams consistently underestimate the unmount lifecycle. Skip it and you accumulate event listener leaks on every navigation, which becomes very visible in long-running SPA sessions.
Common mistakes
Global CSS leaks across MFs. One team adds .btn { color: red !important } to their global stylesheet and every button in every other MF turns red.
/* Wrong: no scope, cascades everywhere */
.btn { color: red !important; } /* Breaks every sibling MF */
/* Fix: CSS Modules scopes it to the component */
/* MF1.module.css */
.btn { color: blue; } /* Only applies inside MF1 */Use CSS Modules or Shadow DOM. Shadow DOM is stricter but requires more setup.
React loaded five times. Without Module Federation's singleton config, each MF bundles its own copy of React. Five MFs means roughly 5 x 130kb of duplicate bundle weight.
// webpack.config.js - required for shared dependencies
new ModuleFederationPlugin({
shared: {
react: { singleton: true, requiredVersion: '^18.0.0' },
'react-dom': { singleton: true, requiredVersion: '^18.0.0' }
}
})Tight coupling through window variables. Teams use window.userState = {} as a shared store. Two MFs overwrite it on mount in a race condition. Use Custom Events or BroadcastChannel instead.
// Wrong: shared mutable global
window.userState = { userId: 123 }; // Overwritten by next MF that mounts
// Fix: event-based communication
window.dispatchEvent(new CustomEvent('user-login', { detail: { userId: 123 } }));
// For cross-origin MFs (iframe-based or separate origins):
const channel = new BroadcastChannel('mf-channel');
channel.postMessage({ type: 'user-login', userId: 123 });Direct DOM manipulation across MF boundaries. MF A does document.getElementById('mf-b-root').innerHTML = ''. This breaks MF B's unmount lifecycle and causes framework errors about unmounted trees. Never touch another MF's DOM directly.
Skipping the unmount lifecycle. If your MF does not clean up event listeners and timers on unmount, you accumulate leaks every time the user navigates. Always implement unmount in single-spa or return a cleanup function in Module Federation.
Real-world usage
- Netflix runs 100+ MFs via single-spa; the shops UI team deploys weekly without touching the recommendations engine
- IKEA uses Module Federation with separate landing, tradfri, and catalog MFs in their own repos
- Spotify uses Webpack Module Federation; podcast and player MFs share an RxJS singleton
- Zalando runs 200+ MFs with the Luigi framework, mixing Angular and React across teams
- Teams typically reach for microfrontends when cross-team deploys happen more than once a week and coordination costs become visible in sprint planning
Follow-up questions
Q: How do you handle shared dependencies like React across MFs?
A: Version-align through Module Federation singletons: shared: { react: { singleton: true, strictVersion: true } }. If versions diverge, the bundle fallback loads a second copy, which is the signal to fix the mismatch.
Q: What is the performance cost of runtime composition?
A: Initial load adds roughly 200-500ms for extra JS round trips. Mitigate with <link rel="modulepreload"> for predictable MF bundles and lazy-loading for routes the user has not visited yet.
Q: How do you sync state across MFs without a global Redux store?
A: BroadcastChannel handles loose events between MFs, including across iframes. URL params carry navigation state. For tighter sync (like a cart count badge in the header), a small shared state module exposed via Module Federation works, but keep it minimal.
Q: Shadow DOM or CSS-in-JS for style isolation?
A: Shadow DOM gives total isolation using constructable stylesheets (Chrome 89+). CSS-in-JS (Styled Components, Emotion) gives scoped styles with less strict boundaries. Shadow DOM is safer across framework boundaries; CSS-in-JS is easier if all MFs share one framework.
Q: (Senior) In a hybrid migration from a monolith to microfrontends, how do you avoid downtime?
A: Proxy the legacy route /old to the monolith via iframe or reverse proxy. Deploy the new MF behind a feature flag. Use single-spa import maps to swap the module URL at runtime: "legacy-app" points to the monolith bundle today and to the new MF bundle after cutover. Rolling back means updating the import map, no redeploy needed.
Q: How does SSR work with microfrontends?
A: The shell SSRs its static structure plus a hydration map. Each MF SSRs its own chunk, stitched together via Edge-Side Includes (ESI) at the CDN layer. Podium is a framework built for this pattern. Most teams start with CSR and add SSR per MF only where SEO or FCP metrics actually matter.
Examples
Module Federation: shell loads a Vue cart widget into a React product list
// shell/webpack.config.js
const { ModuleFederationPlugin } = require('webpack').container;
module.exports = {
plugins: [
new ModuleFederationPlugin({
name: 'shell',
remotes: {
// Cart MF lives at its own URL
cart: 'cart@http://localhost:3002/remoteEntry.js',
},
shared: {
react: { singleton: true },
'react-dom': { singleton: true }
}
})
]
};// shell/src/ProductList.jsx
import React, { lazy, Suspense } from 'react';
// Loaded from the remote Vue MF at runtime
const Cart = lazy(() => import('cart/CartWidget'));
export default function ProductList() {
return (
<div>
<h2>Products</h2>
<Suspense fallback={<div>Loading cart...</div>}>
<Cart /> {/* Vue component renders inside a React tree */}
</Suspense>
</div>
);
}When the user loads /products, the shell fetches remoteEntry.js from localhost:3002, gets the Cart bundle, and React renders it via Suspense. The Vue team deploys a new cart version and users see it on next page load. No shell redeploy needed.
Cross-MF communication with BroadcastChannel
Custom Events work for same-origin MFs. For cross-origin or iframe-based MFs, BroadcastChannel is the right tool.
// Navbar MF (React) - user logs in, broadcasts to all other MFs
function Navbar() {
const handleLogin = () => {
const channel = new BroadcastChannel('mf-events');
channel.postMessage({ type: 'user-login', userId: 123 });
};
return <button onClick={handleLogin}>Login</button>;
}
// Dashboard MF (Vue) - receives the event
// In mounted() or setup()
const channel = new BroadcastChannel('mf-events');
channel.addEventListener('message', (event) => {
if (event.data.type === 'user-login') {
console.log('User logged in:', event.data.userId);
// Update local state
}
});
// Clean up when MF unmounts - always
channel.close();BroadcastChannel works across separate browsing contexts on the same origin. If you use iframes with different origins, you need postMessage with an explicit targetOrigin.
Legacy migration: old Angular app running alongside a new React MF
// single-spa root-config.js
import { registerApplication, start } from 'single-spa';
// Legacy monolith section (Angular, not yet replaced)
registerApplication({
name: 'legacy-checkout',
app: () => System.import('http://legacy.internal/checkout.js'),
activeWhen: ['/checkout'],
});
// New React MF - same route, gated by feature flag
registerApplication({
name: 'new-checkout',
app: () => System.import('http://mf-checkout.internal/remoteEntry.js'),
activeWhen: ['/checkout'],
customProps: {
enabled: window.__FLAGS__?.newCheckout ?? false,
},
});
start();The feature flag switches traffic between legacy and new. Once the new MF passes QA, flip the flag. Both apps coexist during migration. The Angular team keeps shipping; the React team builds alongside them with no shared deploy pipeline.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.