We split a monolithic React application across 50 autonomous product teams serving 3 continents. What followed was 18 months of lessons—some painful—about what micro-frontends are actually good for, where they hurt, and how to make them work at scale.
This isn't a theoretical overview. It's a field report from production, including the mistakes we made and wouldn't make again.
Why Micro-Frontends (and Why Not)
The honest answer: micro-frontends are an organisational pattern, not a technical one. If your teams can coordinate, a monolith is simpler. But when 50 teams need to ship independently without blocking each other, the calculus changes.
- Good fit: large org, multiple teams, distinct product boundaries, independent deployment requirements
- Bad fit: small team, shared design system that changes frequently, tight coupling between features
The micro-frontend cost is real: bundle duplication, shared state complexity, cross-app debugging difficulty. Make sure the organisational benefit outweighs it.
Module Federation
Webpack 5 Module Federation lets one app (remote) expose modules that another app (host) consumes at runtime — without rebuilding the host.
// product-app/webpack.config.js (remote)
new ModuleFederationPlugin({
name: 'product',
filename: 'remoteEntry.js',
exposes: {
'./ProductList': './src/components/ProductList',
'./ProductDetail': './src/pages/ProductDetail',
},
shared: {
react: { singleton: true, requiredVersion: '^18.0.0' },
'react-dom': { singleton: true, requiredVersion: '^18.0.0' },
},
})
// shell-app/webpack.config.js (host)
new ModuleFederationPlugin({
name: 'shell',
remotes: {
product: 'product@https://product.suboorkhan.com/remoteEntry.js',
checkout: 'checkout@https://checkout.suboorkhan.com/remoteEntry.js',
auth: 'auth@https://auth.suboorkhan.com/remoteEntry.js',
},
shared: { react: { singleton: true }, 'react-dom': { singleton: true } },
})
50
Teams shipping
0
Forced co-ordination
99.9%
Shell uptime
Cross-App Routing
Each micro-frontend owns its route subtree. The shell maps URL prefixes to remotes. We use a custom event bus for navigation so remotes don't import from the shell.
// Shell routing
const routes = [
{ path: '/products/*', remote: () => import('product/router') },
{ path: '/checkout/*', remote: () => import('checkout/router') },
{ path: '/account/*', remote: () => import('account/router') },
];
// Cross-micro-frontend navigation via custom events
window.dispatchEvent(new CustomEvent('mf:navigate', {
detail: { path: '/checkout/cart', state: { items } }
}));
Shared State Without Coupling
We use a tiny shared event bus package (< 1KB) published to our private npm registry. Session state (user, cart count) is shared via URL params and localStorage only — never direct store access across app boundaries.
// @internal/event-bus (shared singleton)
const bus = {
emit: (event, data) => window.dispatchEvent(new CustomEvent(event, {detail:data})),
on: (event, fn) => { window.addEventListener(event, e => fn(e.detail)); },
off: (event, fn) => window.removeEventListener(event, fn),
};
// Contract: auth emits 'user:updated' after login
// Cart listens to 'user:updated' to refresh cart count — no direct imports
Hard-Won Lessons
- Version-lock your design system. A shared component that changes silently broke 12 teams in one day. Pin versions and use a changelog-enforced bump process.
- Error isolation is non-negotiable. Each remote must have an ErrorBoundary. One Remote crashing should not kill the shell.
- Test contracts, not implementations. Use Consumer-Driven Contract Testing (Pact) to validate that host/remote module interfaces are stable without both deploying in the same test env.
- Cache your remoteEntry.js aggressively. We moved to immutable CDN URLs with content hashes — shells cache the manifest, not the entry point, for instant updates.
- Shared dependencies are a footgun.
singleton: truefails silently if version ranges don't overlap. Add version-compatibility CI checks.
Summary
- Micro-frontends solve an organisational problem — don't use them for small teams
- Webpack Module Federation is the mature choice for runtime composition in 2026
- Route ownership per remote keeps concerns separate — cross-app navigation via events
- Share as little state as possible — prefer events and URL params over shared stores
- Error boundaries, contract tests, and immutable CDN URLs are non-negotiable