All Posts
ArchitectureFrontendEngineering
Advanced

Micro-Frontends at Scale: Lessons from the Trenches

Building distributed frontend systems that actually work — orchestration, shared state, and the pitfalls nobody talks about.

M
Mini Bhati··12 min read
0

TLDR: Micro-frontends solve an org problem, not a technical one. This covers composition strategies (build-time vs runtime vs edge SSR), the shared state trap, design system deduplication, and the honest answer on when it's actually worth the overhead — which is almost never below five teams working on the same frontend.

Micro-frontends have been "the next big thing" in frontend architecture for years. The pitch is compelling: independent deployability, team autonomy, technology freedom. The reality is messier. Here's what the blog posts don't tell you.

The actual value proposition

Micro-frontends solve an organizational problem, not a technical one. If you have a monolithic frontend and one team, you almost certainly don't need them. If you have six teams shipping to the same codebase on different schedules, the deployment coupling becomes genuinely painful.

I've watched teams adopt micro-frontends for the wrong reasons — usually "we can use different frameworks per team." That's a trap. You end up with four framework versions to maintain, four different approaches to styling, and onboarding takes twice as long because nothing is consistent.

The actual value is this: Team A can ship on Monday without waiting for Team B to finish their feature. Independent deployment is the goal. Everything else is a side effect.

Composition strategies

There are three main ways to stitch micro-frontends together, and each has a different set of tradeoffs that become obvious only after you've lived with them.

Build-time integration

Teams publish versioned npm packages. A shell app imports them and builds a single bundle.

// shell/package.json
{
  "dependencies": {
    "@acme/checkout": "^2.4.0",
    "@acme/product-catalog": "^1.12.0",
    "@acme/user-profile": "^3.1.0"
  }
}

Pros: Simple. Great performance. Type safety across teams. Cons: Defeats independent deployability. A hotfix in checkout means re-building and re-deploying the shell.

This is the approach that looks like micro-frontends but behaves like a monolith at deployment time. Teams realize this about three months in.

Runtime integration via Module Federation

Webpack 5's Module Federation (and now native import maps) lets the shell load remote modules at runtime. Teams ship their own bundles independently.

// webpack.config.js — Shell app
new ModuleFederationPlugin({
  remotes: {
    checkout: 'checkout@https://checkout.acme.com/remoteEntry.js',
    catalog: 'catalog@https://catalog.acme.com/remoteEntry.js',
  },
  shared: ['react', 'react-dom'],
});

// Usage in shell
const CheckoutFlow = React.lazy(() => import('checkout/CheckoutFlow'));

Pros: True independent deployability. Teams ship when they're ready. Cons: Version management of shared libraries is a minefield. If checkout ships with React 18.3 and the shell is on 18.2, you're debugging subtle render differences at 2am wondering why your component looks slightly wrong in production but not locally.

Server-side composition (Edge)

Each team owns a route or fragment. A gateway stitches the HTML together.

User Request
     │
     ▼
 Edge Gateway
     │
     ├──► /header      → header-service:3001
     ├──► /content     → content-service:3002
     └──► /footer      → footer-service:3003

This is the most resilient model — a team's service going down degrades gracefully instead of breaking the whole app. Netflix's Rapid platform and Zalando's Tailor use variations of this. It's also the most infrastructure-heavy to set up.

The hard parts nobody talks about

Shared state is a trap

The instinct is to create a global state bus so micro-frontends can communicate. Resist it hard.

// Tempting but creates invisible coupling
window.__appState = { user: currentUser, cart: cartItems };

The moment two teams share mutable state, you've re-created a monolith at runtime. The independence you wanted is gone — now Team A's bug can corrupt Team B's state. Use the URL, custom events with versioned payloads, or explicit contracts instead:

// Explicit events with versioned payloads — much safer
window.dispatchEvent(new CustomEvent('cart:item-added', {
  detail: { productId: '123', version: 1 }
}));

Adding version: 1 to event payloads is the difference between a clean upgrade path and a breaking change that neither team notices until it's in production.

Design system duplication

If every micro-frontend ships its own button component, your bundle triples and your UI diverges visually within a quarter. You need a shared design system — but that system becomes a deployment dependency again.

The pragmatic answer: version the design system aggressively and deduplicate it at the bundler level:

// Module Federation shared config
shared: {
  'acme-design-system': {
    singleton: true,        // Only one instance at runtime
    requiredVersion: '^4',  // Accept any compatible version
    eager: false,
  }
}

Cross-MFE navigation

Routing is surprisingly painful. If checkout wants to navigate back to catalog, it can't import { Link } from 'acme-catalog'. Use a navigation contract injected by the shell:

// Shared navigation contract — lightweight, no coupling
interface NavigationContract {
  navigateTo(route: string, params?: Record<string, string>): void;
}

// Injected by the shell at mount time, consumed by MFEs
window.__navigate = shellRouter.navigate.bind(shellRouter);

Local development is rough

Running all services locally is painful. This is the friction that kills developer experience — a team member working on checkout needs the shell, the catalog, the header service, and the auth service all running simultaneously. Invest in local dev tooling early. A docker-compose.yml that spins up the full stack is table stakes.

When to actually use micro-frontends

Honest answer: when you have five or more teams working on the same frontend and deployment coordination is genuinely slowing you down. Below that threshold, a well-structured monorepo with clear module boundaries gives you 80% of the benefit at 10% of the cost.

The architecture serves the org chart. Draw the org chart first.

Decision checklist

  • What is your composition strategy? (build-time, runtime, SSR)
  • What do teams share? (design system, auth, routing contracts)
  • How do micro-frontends communicate? (events, URL state, explicit contracts — not shared mutable state)
  • What's the local development story? (make it simple or your DX suffers)
  • How do you handle failures? (graceful degradation per fragment)
  • Who owns the shell? (it needs dedicated ownership, not "everyone's problem")

Micro-frontends are a real solution to a real organizational problem. They trade code complexity for deployment independence. Go in with that tradeoff fully understood, and they work well. Go in thinking they'll solve technical problems and you'll wonder why you made everything harder.

Found this useful? Give it a like.

Newsletter

Stay in the loop

New writing on frontend engineering, system architecture & AI — delivered straight to your inbox. No spam, unsubscribe anytime.