The Architecture Decision

Three approaches merit evaluation:

  1. Module Federation 2.0 (via Rspack), Runtime module sharing, mature ecosystem, webpack/rspack-specific
  2. Native Federation, ES modules + import maps, bundler-agnostic, newer
  3. Build-time composition (npm packages), Each MFE published as an npm package, composed at build time

Module Federation 2.0 with Rspack for the core application and import maps as the discovery layer represents the strongest combination. Here is why.

Module Federation 2.0: What Changed

MF 2.0 (shipped with Rspack 1.x and webpack 5.9x) introduced three things that made it actually usable for production:

1. Manifest protocol. Instead of hardcoding remote URLs in webpack config, remotes publish a mf-manifest.json that describes their exposed modules, shared dependencies, and entry points:

{
 "id": "checkout-mfe",
 "name": "checkout",
 "metaData": {
 "buildHash": "a3f8c2d",
 "version": "2.14.0",
 "framework": "react@18.3.1"
 },
 "shared": [
 {
 "name": "react",
 "version": "18.3.1",
 "scope": "default",
 "strategy": "loaded-first"
 }
 ],
 "exposes": [
 {
 "path": "./CheckoutFlow",
 "name": "CheckoutFlow",
 "assets": {
 "js": { "async": ["checkout-flow.js"], "sync": [] },
 "css": { "async": ["checkout-flow.css"], "sync": [] }
 }
 }
 ]
}

2. Runtime plugins. You can intercept the entire federation lifecycle, before loading a remote, after resolving shared dependencies, on error. This is how A/B testing can be implemented at the micro-frontend level:

// host/federation-plugin.js
import { FederationRuntimePlugin } from '@module-federation/runtime';

const ABTestPlugin = () => ({
 name: 'ab-test-plugin',
 beforeRequest(args) {
 const { id, options } = args;

 // Check if this remote has an A/B test active
 const variant = getABVariant(id);
 if (variant) {
 // Redirect to the variant's manifest
 options.remotes = options.remotes.map(remote => {
 if (remote.name === id) {
 return {
 ...remote,
 entry: remote.entry.replace(
 'mf-manifest.json',
 `mf-manifest-${variant}.json`
 )
 };
 }
 return remote;
 });
 }

 return args;
 }
});

export default ABTestPlugin;

3. Type generation. MF 2.0 generates TypeScript declarations for exposed modules and syncs them across micro-frontends via a manifest. No more // @ts-ignore on every dynamic import.

The Host Application

The host (shell) application is minimal, it handles routing, authentication, and the navigation chrome. Each route maps to a remote micro-frontend:

// host/src/App.jsx
import { lazy, Suspense } from 'react';
import { init, loadRemote } from '@module-federation/enhanced/runtime';
import { Routes, Route } from 'react-router-dom';
import ABTestPlugin from './federation-plugin';

// Initialize Module Federation runtime
init({
 name: 'host',
 remotes: [
 {
 name: 'dashboard',
 entry: `${window.__MFE_CONFIG__.dashboard}/mf-manifest.json`
 },
 {
 name: 'checkout',
 entry: `${window.__MFE_CONFIG__.checkout}/mf-manifest.json`
 },
 {
 name: 'accounts',
 entry: `${window.__MFE_CONFIG__.accounts}/mf-manifest.json`
 },
 {
 name: 'settings',
 entry: `${window.__MFE_CONFIG__.settings}/mf-manifest.json`
 }
 ],
 shared: {
 react: { singleton: true, requiredVersion: '^18.3.0' },
 'react-dom': { singleton: true, requiredVersion: '^18.3.0' },
 'react-router-dom': { singleton: true, requiredVersion: '^6.20.0' }
 },
 plugins: [ABTestPlugin()]
});

// Lazy-load micro-frontends
const Dashboard = lazy(() => loadRemote('dashboard/DashboardApp'));
const Checkout = lazy(() => loadRemote('checkout/CheckoutFlow'));
const Accounts = lazy(() => loadRemote('accounts/AccountsApp'));
const Settings = lazy(() => loadRemote('settings/SettingsApp'));

function App() {
 return (
 <Shell>
 <Suspense fallback={<MFELoadingSkeleton />}>
 <Routes>
 <Route path="/dashboard/*" element={<Dashboard />} />
 <Route path="/checkout/*" element={<Checkout />} />
 <Route path="/accounts/*" element={<Accounts />} />
 <Route path="/settings/*" element={<Settings />} />
 </Routes>
 </Suspense>
 </Shell>
 );
}

The window.__MFE_CONFIG__ object is injected by the deployment pipeline. In production, it points to CDN URLs. In development, developers can override specific entries to point at localhost:3001, localhost:3002, etc.

Import Maps as the Discovery Layer

This is where import maps fit in. Instead of hardcoding MFE URLs in the host's JavaScript bundle, an import map is served from a CDN that the host reads at startup:

<!-- index.html -->
<script type="importmap">
{
 "imports": {
 "dashboard-mfe": "https://cdn.example.com/mfe/dashboard/v2.14.0/",
 "checkout-mfe": "https://cdn.example.com/mfe/checkout/v3.2.1/",
 "accounts-mfe": "https://cdn.example.com/mfe/accounts/v1.8.0/",
 "settings-mfe": "https://cdn.example.com/mfe/settings/v2.0.0/"
 }
}
</script>

This import map is generated by the CI pipeline whenever a MFE deploys. The critical advantage: you can update which version of a MFE is served without redeploying the host application. Deploy checkout v3.2.2? Just update the import map. Rollback? Update the import map again. The host never changes.

For development, overrides are injected:

// dev-overrides.js, loaded only in non-production environments
if (localStorage.getItem('mfe-overrides')) {
 const overrides = JSON.parse(localStorage.getItem('mfe-overrides'));
 // Override Module Federation remote entries
 Object.entries(overrides).forEach(([name, url]) => {
 window.__MFE_CONFIG__[name] = url;
 });
}

A developer working on the checkout MFE sets mfe-overrides in localStorage to {"checkout": "http://localhost:3002"} and immediately gets their local checkout MFE running inside the production host. No need to run the entire application locally.

Shared State: The Hard Problem

This is where most micro-frontend architectures fall apart. Four teams, seven MFEs, and they all need to know who the current user is, what their permissions are, and whether the shopping cart has items.

The temptation is to share a Redux store. This is inadvisable. A shared store creates implicit coupling between MFEs, team A changes a reducer, team B's selectors break, and nobody catches it until production because they deploy independently.

Instead, a three-layer approach works well:

Layer 1: Authentication Context (Host-Owned)

The host owns authentication state and exposes it via a custom event bus:

// host/src/auth-bus.js
class AuthBus {
 #listeners = new Set();
 #currentUser = null;

 setUser(user) {
 this.#currentUser = Object.freeze(user);
 this.#notify('user-changed', this.#currentUser);
 }

 getUser() {
 return this.#currentUser;
 }

 subscribe(callback) {
 this.#listeners.add(callback);
 // Immediately emit current state to new subscribers
 if (this.#currentUser) {
 callback({ type: 'user-changed', payload: this.#currentUser });
 }
 return () => this.#listeners.delete(callback);
 }

 #notify(type, payload) {
 const event = Object.freeze({ type, payload });
 this.#listeners.forEach(cb => cb(event));
 }
}

// Singleton exposed on window for cross-MFE access
window.__AUTH_BUS__ = new AuthBus();

Layer 2: Cross-MFE Events (BroadcastChannel)

For events that any MFE might produce and any other might consume (like "item added to cart"), BroadcastChannel works well:

// shared-lib/src/mfe-events.js
const channel = new BroadcastChannel('mfe-events');

export function emitMFEEvent(type, payload, source) {
 channel.postMessage({
 type,
 payload,
 source,
 timestamp: Date.now()
 });
}

export function onMFEEvent(type, callback) {
 const handler = (event) => {
 if (event.data.type === type) {
 callback(event.data);
 }
 };
 channel.addEventListener('message', handler);
 return () => channel.removeEventListener('message', handler);
}

// Usage in checkout MFE:
// emitMFEEvent('cart:item-added', { productId: '123', qty: 1 }, 'checkout');

// Usage in nav MFE:
// onMFEEvent('cart:item-added', (event) => updateCartBadge(event.payload));

Layer 3: URL as State (Router-Driven)

Shared navigation state lives in the URL. If the dashboard needs to link to a specific account, it navigates to /accounts/ACC-123. The accounts MFE reads its route params. No shared state needed.

The Migration: Strangler Fig Pattern

The monolith was not rewritten. It was strangled. Here is a representative timeline:

Month 1-2: Set up the host shell, Module Federation infrastructure, and CI/CD pipeline. The host rendered the entire monolith as a single "legacy" remote.

Month 3-4: Extracted the settings page (lowest risk, fewest dependencies) into its own MFE. Two engineers, two weeks of actual coding. The rest was CI/CD plumbing.

Month 5-6: Extracted checkout (highest business value, most complex). This required the shared state architecture described above. Three engineers, six weeks.

Month 7-10: Extracted dashboard and accounts in parallel. By this point, the patterns were established and each extraction took 3-4 weeks per team.

Month 11-12: Decommissioned the legacy monolith remote. What remained was the host shell + four independent MFEs.

Common Pitfalls

  1. CSS isolation. Without CSS isolation enforced from day one, MFE A's global styles bleed into MFE B. The solution: Shadow DOM for new MFEs and a PostCSS plugin that scopes all selectors for legacy code.

  2. Shared dependency version drift. Within three months, two MFEs were on React 18.3.1 and two on 18.2.0. Module Federation's singleton mode caused one team's hooks to break silently. Adding a CI check that fails if shared dependency versions drift beyond a minor version prevents this.

  3. Error boundaries. When a remote MFE fails to load (CDN outage, network issue), the entire host crashes without proper error boundaries wrapping loadRemote(). Every MFE mount point should have a <MFEErrorBoundary> that shows a retry UI.

class MFEErrorBoundary extends React.Component {
 state = { hasError: false, error: null };

 static getDerivedStateFromError(error) {
 return { hasError: true, error };
 }

 handleRetry = () => {
 this.setState({ hasError: false, error: null });
 };

 render() {
 if (this.state.hasError) {
 return (
 <div className="mfe-error">
 <p>This section failed to load.</p>
 <button onClick={this.handleRetry}>Retry</button>
 <details>
 <summary>Technical details</summary>
 <pre>{this.state.error?.message}</pre>
 </details>
 </div>
 );
 }
 return this.props.children;
 }
}

Performance Impact

Key performance metrics:

Metric Monolith Micro-Frontends Delta
Initial JS bundle 2.4MB (gzipped 680KB) 320KB host + lazy MFEs -53% initial load
Time to Interactive 4.2s 1.8s (host) + 0.6s (first MFE) -43%
Deploy time 22 min (full build + E2E) 3-4 min per MFE -82%
Deploy frequency 2-3/day (all teams combined) 3-5/day per team +400%

The initial load improved because users only download the code for the page they're visiting. The dashboard team's 1.8MB of charting libraries doesn't get downloaded until someone visits /dashboard.

The tradeoff: there's a brief loading state when navigating between MFEs for the first time as the remote bundle downloads. Preloading the most likely next MFE based on user navigation patterns mitigates this for 80% of transitions.

When Micro-Frontends Make Sense

Micro-frontends are justified only for organizations with the team structure that demands them. Micro-frontends are an organizational solution to an organizational problem. If you have one team, use a monolith. If you have two teams, use a monorepo with code ownership. If you have four or more teams that need independent deployment cadences, micro-frontends are worth the complexity.

The technology is finally mature enough that the implementation pain is manageable. Module Federation 2.0's runtime plugins and manifest protocol solved the problems that made MF 1.0 painful. Import maps give you a deployment mechanism that's independent of any bundler. And the shared state patterns, event bus, BroadcastChannel, URL-as-state, are simple enough that new engineers understand them in a day.

The guidance: start with a monolith. Extract when the organizational pain demands it. And when extracting, use the strangler fig pattern. It is the only migration approach that allows shipping features to users every week while the decomposition happens in the background.