The Monolith Problem
- Merge conflicts as a way of life. Every team touches the same App.tsx, the same routing config, the same global state store.
- A build pipeline that takes 20+ minutes. As the codebase grows, so does the bundle graph, and CI time grows with it.
- Coordinated release trains. Nobody can ship independently — every feature waits for a shared release date because everything is bundled together.
- Fear of touching shared code. Nobody wants to refactor a shared utility because it might break three other teams' features.
What is a Micro-Frontend?
- Product Search Team: Owns the search bar, filtering UI, and search results page.
- Product Details Team: Owns the individual product page, reviews, and image galleries.
- Checkout Team: Owns the shopping cart and payment flow.
Integration Approaches
1. Server-Side Integration (SSI/ESI)
Html
<!-- Simplified server template -->
<html>
<body>
<esi:include src="https://search.internal/fragment" />
<esi:include src="https://cart.internal/fragment" />
</body>
</html>
2. Build-Time Integration (NPM Packages)
Json
{
"dependencies": {
"@company/search-mfe": "^2.3.0",
"@company/cart-mfe": "^1.8.1",
"@company/checkout-mfe": "^4.1.0"
}
}
3. Run-Time Integration (Module Federation)
Js
// webpack.config.js — search-mfe (remote)
const { ModuleFederationPlugin } = require("webpack").container;
module.exports = {
plugins: [
new ModuleFederationPlugin({
name: "searchMfe",
filename: "remoteEntry.js",
exposes: {
"./SearchWidget": "./src/SearchWidget",
},
shared: { react: { singleton: true }, "react-dom": { singleton: true } },
}),
],
};
Js
// webpack.config.js — shell (host)
new ModuleFederationPlugin({
name: "shell",
remotes: {
searchMfe: "searchMfe@https://search.example.com/remoteEntry.js",
},
shared: { react: { singleton: true }, "react-dom": { singleton: true } },
});
Jsx
// Consuming the remote in the shell
const SearchWidget = React.lazy(() => import("searchMfe/SearchWidget"));
function App() {
return (
<Suspense fallback={<Spinner />}>
<SearchWidget />
</Suspense>
);
}
How to Get Started
Step 1: Identify seams, not just features
Step 2: Build the shell first
- Top-level routing
- Authentication and session context
- The design system / shared component library
- Error boundaries around each micro-frontend
Step 3: Extract one micro-frontend
Step 4: Establish contracts early
- How micro-frontends communicate (see below)
- Which design tokens and components are shared vs. owned locally
- What the deployment and versioning process looks like
- What "done" looks like for a team's ownership boundary
Step 5: Repeat, and retire the monolith gradually
Technical Guidelines
Routing
State Sharing
- Custom browser events: window.dispatchEvent(new CustomEvent("cart:item-added", { detail: item })). Loosely coupled, framework-agnostic, but harder to type and debug.
- URL parameters / query state: Good for state that should be shareable or bookmarkable, bad for anything high-frequency.
- A shared, framework-agnostic store: A small pub-sub or observable store (not Redux tied to one micro-frontend) that any micro-frontend can subscribe to. This needs to be treated as a public API with its own versioning discipline.
- Backend-for-Frontend (BFF) as source of truth: For state that really belongs on the server (cart contents, user session), let the server be the single source of truth and have each micro-frontend fetch independently.
Shared Dependencies
- Pin shared dependency versions across teams, or use semver ranges deliberately with singleton: true and strictVersion where it matters.
- Audit bundle output regularly — it's easy for a shared dependency to silently duplicate after a version bump goes unnoticed.
- Treat "which libraries are shared" as a governance decision, not an accident of who set up Module Federation first.
Design Consistency
Css
:root {
--color-primary: #1a56db;
--spacing-unit: 8px;
--radius-md: 6px;
}
CSS Isolation
- Shadow DOM for hard isolation (heaviest, but bulletproof).
- CSS Modules or scoped CSS-in-JS so class names are automatically namespaced.
- A strict naming convention (e.g., BEM with a team prefix) if you can't use the above.
Error Isolation
Jsx
<ErrorBoundary fallback={<CheckoutUnavailable />}>
<Suspense fallback={<Spinner />}>
<CheckoutWidget />
</Suspense>
</ErrorBoundary>
Testing Strategy
- Unit and component tests live inside each micro-frontend's own repo, owned entirely by that team.
- Contract tests verify that a micro-frontend's exposed interface (props, events, exposed modules) still matches what the shell expects — these catch breaking changes before they reach production.
- End-to-end tests run against a fully composed environment (staging) and cover cross-micro-frontend flows, like adding an item in Search and seeing it reflected in Cart.
- Visual regression tests catch design-system drift across teams.
Deployment and Versioning
- Its own CI/CD pipeline, triggered by its own repo.
- A versioned remoteEntry.js (or equivalent), often published to a CDN with cache-busting per deploy.
- A rollback strategy that doesn't depend on the shell — if a micro-frontend ships a bad build, you should be able to point the shell back at a previous version without redeploying the shell itself.
- Feature flags at the shell level, so a broken micro-frontend can be disabled without a full rollback.
Observability
- Error tracking (tagged by micro-frontend name/version, so a crash is attributable to the right team).
- Performance monitoring (bundle size over time, load time per fragment).
- A health-check or "unavailable" fallback state the shell can detect and gracefully degrade around.
The Pitfalls
- Performance Overhead: Duplicated dependencies (React, date libraries, UI kits) are the most common way micro-frontend migrations quietly regress page-load performance. Proper configuration — shared dependencies in Module Federation, aggressive bundle auditing — is critical, not optional.
- Design Inconsistencies: Without a strong, centralized Design System (CSS variables, shared component library), the application will look disjointed, and users will notice even if they can't articulate why.
- State Sharing: Passing state via URL parameters, custom browser events, or a shared global store requires careful architectural planning up front — retrofitting it after five teams have improvised their own approach is painful.
- Operational Complexity: More repos, more pipelines, more moving parts to monitor. This is a real cost that only pays off once you have enough teams to justify it.
- Debugging Across Boundaries: A bug that spans two micro-frontends (say, a stale event listener between Search and Cart) is harder to trace than a bug in a single codebase, because the two teams may not even use the same tooling.
- Governance Overhead: Someone needs to own the shared contracts — the event schema, the design tokens, the shared dependency versions — or they drift and rot.
When (Not) to Use Micro-Frontends
- 30+ frontend engineers across multiple teams and time zones
- Teams that need to deploy independently, on their own schedules
- A product surface that's naturally divisible along team/domain lines (search, cart, checkout, admin)
- A small team of 3–10 developers — the coordination overhead of micro-frontends will slow you down, not speed you up
- A single cohesive feature that doesn't have a natural seam
- A team without the platform/DevOps maturity to support multiple independent pipelines