Micro Frontend Implementation Patterns: Module Federation and Beyond
Production-ready Module Federation setups, cross-app communication, routing strategies, and the race conditions that break split routing under load.
Micro Frontend Series Navigation
- Part 1: Architecture fundamentals and implementation types
- Part 2 (You are here): Module Federation, communication patterns, and integration strategies
- Part 3: Advanced patterns, performance optimization, and production debugging
Prerequisites: Part 1 covers the concepts assumed below. If you’re new to micro frontends, start there.
Part 1 covered the fundamental architectural patterns for micro frontends. Module Federation is the dominant runtime integration approach, and the practical work lives in its setup, the communication patterns around it, and the debugging strategies production systems demand.
Module Federation Deep Dive
Module Federation shipped with Webpack 5 and now carries most of the runtime integration work. Beyond what dynamic imports give you, it adds dependency sharing, version negotiation, and runtime composition.
Setting Up a Production-Ready Module Federation System
Let’s build a realistic e-commerce application with separate teams owning different domains:
// apps/shell/webpack.config.js
// Note: Using stable versions for production reliability rather than bleeding edge.
// @module-federation/enhanced ships Module Federation 2.0; webpack 5's built-in
// plugin still lives at webpack.container.ModuleFederationPlugin
const { ModuleFederationPlugin } = require('@module-federation/enhanced/webpack');
const HtmlWebpackPlugin = require('html-webpack-plugin');
module.exports = {
mode: 'development',
entry: './src/index.ts',
module: {
rules: [
{
test: /\.tsx?$/,
use: 'ts-loader',
exclude: /node_modules/,
},
{
test: /\.css$/,
use: ['style-loader', 'css-loader', 'postcss-loader'],
},
],
},
plugins: [
new ModuleFederationPlugin({
name: 'shell',
filename: 'remoteEntry.js',
remotes: {
// Product team's micro frontend
products: 'products@http://localhost:3001/remoteEntry.js',
// Cart team's micro frontend
cart: 'cart@http://localhost:3002/remoteEntry.js',
// User team's micro frontend
user: 'user@http://localhost:3003/remoteEntry.js',
},
shared: {
// Using stable versions proven in production - not always the latest
// This ensures compatibility across all micro frontends
react: {
singleton: true,
strictVersion: true,
requiredVersion: '^18.2.0',
},
'react-dom': {
singleton: true,
strictVersion: true,
requiredVersion: '^18.2.0',
},
'react-router-dom': {
singleton: true,
requiredVersion: '^6.8.0',
},
// Custom shared utilities
'@company/design-system': {
singleton: true,
requiredVersion: '^2.1.0',
},
'@company/event-bus': {
singleton: true,
requiredVersion: '^1.0.0',
}
},
}),
new HtmlWebpackPlugin({
template: './public/index.html',
}),
],
resolve: {
extensions: ['.tsx', '.ts', '.js'],
},
devServer: {
port: 3000,
historyApiFallback: true,
headers: {
'Access-Control-Allow-Origin': '*',
},
},
};
// apps/products/webpack.config.js
const { ModuleFederationPlugin } = require('@module-federation/enhanced/webpack');
module.exports = {
mode: 'development',
entry: './src/index.ts',
plugins: [
new ModuleFederationPlugin({
name: 'products',
filename: 'remoteEntry.js',
exposes: {
'./ProductList': './src/components/ProductList',
'./ProductDetail': './src/components/ProductDetail',
'./ProductSearch': './src/components/ProductSearch',
},
shared: {
react: {
singleton: true,
requiredVersion: '^18.2.0',
},
'react-dom': {
singleton: true,
requiredVersion: '^18.2.0',
},
'react-router-dom': {
singleton: true,
requiredVersion: '^6.8.0',
},
'@company/design-system': {
singleton: true,
requiredVersion: '^2.1.0',
},
'@company/event-bus': {
singleton: true,
requiredVersion: '^1.0.0',
}
},
}),
],
module: {
rules: [
{
test: /\.tsx?$/,
use: 'ts-loader',
exclude: /node_modules/,
},
],
},
resolve: {
extensions: ['.tsx', '.ts', '.js'],
},
devServer: {
port: 3001,
headers: {
'Access-Control-Allow-Origin': '*',
},
},
};
Robust Module Loading with Error Boundaries
One of the biggest challenges with Module Federation is handling loading failures gracefully. The loader below keeps a failed remote from taking the rest of the page with it:
// src/components/MicroFrontendLoader.tsx
import React, { Suspense, lazy, useState, useEffect } from 'react';
interface MicroFrontendConfig {
scope: string;
module: string;
url: string;
fallback?: React.ComponentType;
}
interface LoadingState {
isLoading: boolean;
error: Error | null;
retryCount: number;
}
const useDynamicScript = (url: string) => {
const [ready, setReady] = useState(false);
const [failed, setFailed] = useState(false);
useEffect(() => {
if (!url) return;
const element = document.createElement('script');
element.src = url;
element.type = 'text/javascript';
element.async = true;
setReady(false);
setFailed(false);
element.onload = () => {
console.log(`Dynamic Script Loaded: ${url}`);
setReady(true);
};
element.onerror = () => {
console.error(`Dynamic Script Error: ${url}`);
setReady(false);
setFailed(true);
};
document.head.appendChild(element);
return () => {
console.log(`Dynamic Script Removed: ${url}`);
document.head.removeChild(element);
};
}, [url]);
return { ready, failed };
};
const loadComponent = (scope: string, module: string) => {
return async () => {
// Initializes the share scope. This fills it with known provided modules from this build and all remotes
await __webpack_init_sharing__('default');
const container = (window as any)[scope]; // or get the container somewhere else
if (!container) {
throw new Error(`Container '${scope}' not found`);
}
// Initialize the container, it may provide shared modules
await container.init(__webpack_share_scopes__.default);
const factory = await (window as any)[scope].get(module);
const Module = factory();
return Module;
};
};
class MicroFrontendErrorBoundary extends React.Component<
{ children: React.ReactNode; fallback: React.ComponentType },
{ hasError: boolean; error: Error | null }
> {
constructor(props: any) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error) {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: any) {
console.error('Micro Frontend Error:', error, errorInfo);
// Send to monitoring service
if (typeof window !== 'undefined' && (window as any).analytics) {
(window as any).analytics.track('Micro Frontend Error', {
error: error.message,
stack: error.stack,
componentStack: errorInfo.componentStack,
});
}
}
render() {
if (this.state.hasError) {
const Fallback = this.props.fallback;
return <Fallback />;
}
return this.props.children;
}
}
export const MicroFrontendLoader: React.FC<{
config: MicroFrontendConfig;
props?: Record<string, any>;
}> = ({ config, props = {} }) => {
const { ready, failed } = useDynamicScript(config.url);
const [loadingState, setLoadingState] = useState<LoadingState>({
isLoading: false,
error: null,
retryCount: 0,
});
useEffect(() => {
if (ready && !loadingState.isLoading) {
setLoadingState(prev => ({ ...prev, isLoading: true, error: null }));
}
}, [ready]);
const handleRetry = () => {
if (loadingState.retryCount < 3) {
setLoadingState(prev => ({
...prev,
retryCount: prev.retryCount + 1,
error: null,
isLoading: true,
}));
// Force reload the script
window.location.reload();
}
};
if (failed || (loadingState.error && loadingState.retryCount >= 3)) {
const Fallback = config.fallback || DefaultErrorFallback;
return <Fallback onRetry={handleRetry} />;
}
if (!ready) {
return <LoadingFallback />;
}
const Component = lazy(loadComponent(config.scope, config.module));
return (
<MicroFrontendErrorBoundary fallback={config.fallback || DefaultErrorFallback}>
<Suspense fallback={<LoadingFallback />}>
<Component {...props} />
</Suspense>
</MicroFrontendErrorBoundary>
);
};
const LoadingFallback: React.FC = () => (
<div className="animate-pulse">
<div className="h-4 bg-gray-300 rounded w-3/4 mb-2"></div>
<div className="h-4 bg-gray-300 rounded w-1/2"></div>
</div>
);
const DefaultErrorFallback: React.FC<{ onRetry?: () => void }> = ({ onRetry }) => (
<div className="p-4 border border-red-300 rounded bg-red-50">
<h3 className="text-red-800 font-semibold">Something went wrong</h3>
<p className="text-red-600 text-sm mt-1">
This section couldn't be loaded. Please try refreshing the page.
</p>
{onRetry && (
<button
onClick={onRetry}
className="mt-2 px-3 py-1 bg-red-600 text-white rounded text-sm"
>
Retry
</button>
)}
</div>
);
Cross-Micro Frontend Communication
One of the most challenging aspects of micro frontend architecture is enabling communication between independently developed and deployed applications. A shared event bus with a small replay buffer covers most of it, and the replay part is what keeps late-loading remotes in sync:
1. Event-Driven Communication
// @company/event-bus - Shared event bus package
import { useCallback, useEffect } from 'react';
interface EventBusEvent {
type: string;
payload: any;
source: string;
timestamp: number;
}
class EventBus {
private listeners: Map<string, Array<(event: EventBusEvent) => void>> = new Map();
private eventHistory: EventBusEvent[] = [];
private maxHistorySize = 50;
subscribe(eventType: string, callback: (event: EventBusEvent) => void): () => void {
if (!this.listeners.has(eventType)) {
this.listeners.set(eventType, []);
}
this.listeners.get(eventType)!.push(callback);
// Return unsubscribe function
return () => {
const callbacks = this.listeners.get(eventType);
if (callbacks) {
const index = callbacks.indexOf(callback);
if (index > -1) {
callbacks.splice(index, 1);
}
}
};
}
publish(type: string, payload: any, source: string = 'unknown') {
const event: EventBusEvent = {
type,
payload,
source,
timestamp: Date.now(),
};
// Add to history
this.eventHistory.push(event);
if (this.eventHistory.length > this.maxHistorySize) {
this.eventHistory.shift();
}
// Notify listeners
const callbacks = this.listeners.get(type) || [];
callbacks.forEach(callback => {
try {
callback(event);
} catch (error) {
console.error(`Error in event listener for ${type}:`, error);
}
});
// Debug logging in development
if (process.env.NODE_ENV === 'development') {
console.log(`[EventBus] ${type}:`, payload);
}
}
getHistory(eventType?: string): EventBusEvent[] {
if (eventType) {
return this.eventHistory.filter(event => event.type === eventType);
}
return [...this.eventHistory];
}
// Replay events for late-loading micro frontends
replayEvents(eventType: string, callback: (event: EventBusEvent) => void) {
const pastEvents = this.eventHistory.filter(event => event.type === eventType);
pastEvents.forEach(event => callback(event));
}
}
export const eventBus = new EventBus();
// React hook for easier usage
export const useEventBus = (
eventType: string,
callback: (event: EventBusEvent) => void,
deps: React.DependencyList = []
) => {
useEffect(() => {
const unsubscribe = eventBus.subscribe(eventType, callback);
return unsubscribe;
}, deps);
const publish = useCallback((payload: any, source?: string) => {
eventBus.publish(eventType, payload, source);
}, [eventType]);
return { publish };
};
2. Practical Usage in Micro Frontends
// products/src/components/ProductList.tsx
import React, { useState, useEffect } from 'react';
import { useEventBus } from '@company/event-bus';
interface Product {
id: string;
name: string;
price: number;
image: string;
}
export const ProductList: React.FC = () => {
const [products, setProducts] = useState<Product[]>([]);
const [filters, setFilters] = useState<any>(null);
// Listen for filter changes from search micro frontend
useEventBus('search:filters-changed', (event) => {
setFilters(event.payload);
});
// Listen for cart updates to show feedback
useEventBus('cart:item-added', (event) => {
// Show success notification
showNotification(`${event.payload.productName} added to cart!`);
});
const { publish } = useEventBus('products:product-selected', () => {});
const handleProductClick = (product: Product) => {
publish({
productId: product.id,
productName: product.name,
source: 'product-list',
}, 'products');
};
useEffect(() => {
// Apply filters when they change
if (filters) {
// Filter products logic here
const filtered = applyFilters(products, filters);
setProducts(filtered);
}
}, [filters]);
return (
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-4 gap-4">
{products.map(product => (
<div
key={product.id}
className="border rounded-lg p-4 cursor-pointer hover:shadow-lg"
onClick={() => handleProductClick(product)}
>
<img src={product.image} alt={product.name} className="w-full h-48 object-cover" />
<h3 className="font-semibold mt-2">{product.name}</h3>
<p className="text-gray-600">${product.price}</p>
</div>
))}
</div>
);
};
// cart/src/components/CartButton.tsx
import React, { useState, useEffect } from 'react';
import { useEventBus } from '@company/event-bus';
export const CartButton: React.FC = () => {
const [itemCount, setItemCount] = useState(0);
const [isAnimating, setIsAnimating] = useState(false);
// Listen for product additions
useEventBus('products:product-selected', (event) => {
addToCart(event.payload.productId);
});
const { publish } = useEventBus('cart:item-added', () => {});
const addToCart = async (productId: string) => {
try {
// Add to cart logic
const response = await fetch('/api/cart/add', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ productId }),
});
if (response.ok) {
const result = await response.json();
setItemCount(prev => prev + 1);
// Trigger animation
setIsAnimating(true);
setTimeout(() => setIsAnimating(false), 500);
// Notify other micro frontends
publish({
productId,
productName: result.productName,
newTotal: result.cartTotal,
}, 'cart');
}
} catch (error) {
console.error('Failed to add to cart:', error);
}
};
return (
<button
className={`relative p-2 bg-blue-600 text-white rounded-full ${
isAnimating ? 'animate-pulse' : ''
}`}
>
<ShoppingCartIcon className="w-6 h-6" />
{itemCount > 0 && (
<span className="absolute -top-2 -right-2 bg-red-500 text-white text-xs rounded-full w-5 h-5 flex items-center justify-center">
{itemCount}
</span>
)}
</button>
);
};
Routing Strategies
Routing in micro frontend architectures requires careful coordination to avoid conflicts and ensure a seamless user experience:
1. Shell-Controlled Routing
// shell/src/App.tsx
import React from 'react';
import { BrowserRouter, Routes, Route, Navigate, Link } from 'react-router-dom';
import { MicroFrontendLoader } from './components/MicroFrontendLoader';
const App: React.FC = () => {
return (
<BrowserRouter>
<div className="min-h-screen bg-gray-50">
{/* Global navigation */}
<nav className="bg-white shadow-sm border-b">
<div className="max-w-7xl mx-auto px-4">
<div className="flex justify-between h-16">
<div className="flex">
<Link to="/" className="flex items-center px-4 text-lg font-semibold">
E-Commerce
</Link>
<div className="flex space-x-8 ml-8">
<Link to="/products" className="flex items-center px-3 py-2 hover:text-blue-600">
Products
</Link>
<Link to="/categories" className="flex items-center px-3 py-2 hover:text-blue-600">
Categories
</Link>
</div>
</div>
{/* User account micro frontend */}
<div className="flex items-center">
<MicroFrontendLoader
config={{
scope: 'user',
module: './UserMenu',
url: 'http://localhost:3003/remoteEntry.js',
}}
/>
</div>
</div>
</div>
</nav>
{/* Main content area */}
<main className="max-w-7xl mx-auto px-4 py-8">
<Routes>
<Route path="/" element={<Navigate to="/products" replace />} />
{/* Products micro frontend handles all /products/* routes */}
<Route
path="/products/*"
element={
<MicroFrontendLoader
config={{
scope: 'products',
module: './ProductsApp',
url: 'http://localhost:3001/remoteEntry.js',
}}
/>
}
/>
{/* Cart micro frontend */}
<Route
path="/cart/*"
element={
<MicroFrontendLoader
config={{
scope: 'cart',
module: './CartApp',
url: 'http://localhost:3002/remoteEntry.js',
}}
/>
}
/>
{/* Fallback for unknown routes */}
<Route path="*" element={<NotFoundPage />} />
</Routes>
</main>
</div>
</BrowserRouter>
);
};
2. Micro Frontend Internal Routing
// products/src/ProductsApp.tsx
import React, { useEffect } from 'react';
import { Routes, Route, useLocation } from 'react-router-dom';
import { ProductList } from './components/ProductList';
import { ProductDetail } from './components/ProductDetail';
import { ProductSearch } from './components/ProductSearch';
export const ProductsApp: React.FC = () => {
const location = useLocation();
// Analytics tracking for micro frontend
useEffect(() => {
if (typeof window !== 'undefined' && (window as any).analytics) {
(window as any).analytics.page('Products', {
path: location.pathname,
microfrontend: 'products',
});
}
}, [location.pathname]);
return (
<div className="products-app">
<Routes>
{/* Note: paths are relative to /products */}
<Route index element={<ProductList />} />
<Route path="search" element={<ProductSearch />} />
<Route path="category/:categoryId" element={<ProductList />} />
<Route path=":productId" element={<ProductDetail />} />
</Routes>
</div>
);
};
Route Registration Race Conditions
Split routing has a failure mode that looks random from the outside: a product detail page returns a 404 for some users, some of the time, and reloading fixes it. The cause is usually a race between the shell’s router and a remote that has not registered its routes yet.
The shape that triggers it:
// The problematic code
const ProductsApp: React.FC = () => {
const [isLoaded, setIsLoaded] = useState(false);
useEffect(() => {
// A fixed timer instead of a readiness signal is what opens the race window
setTimeout(() => setIsLoaded(true), 100);
}, []);
if (!isLoaded) {
return <div>Loading...</div>;
}
return (
<Routes>
<Route path=":productId" element={<ProductDetail />} />
</Routes>
);
};
React Router in the shell matches routes before the micro frontend finishes initializing its own. Because the delay is a fixed timer rather than a readiness signal, the window opens and closes with load timing, which is why the 404s look random.
The fix is to make readiness explicit: the remote announces its routes, then renders them.
// Fixed version with proper route registration
import { useEventBus } from '@company/event-bus';
const ProductsApp: React.FC = () => {
const [routesReady, setRoutesReady] = useState(false);
const { publish } = useEventBus('routing:micro-frontend-ready', () => {});
useEffect(() => {
// Register available routes with the shell
publish({
microfrontend: 'products',
routes: [
'/products',
'/products/search',
'/products/category/:categoryId',
'/products/:productId'
]
}, 'products');
setRoutesReady(true);
}, [publish]);
if (!routesReady) {
return <LoadingSpinner />;
}
return (
<Routes>
<Route index element={<ProductList />} />
<Route path="search" element={<ProductSearch />} />
<Route path="category/:categoryId" element={<ProductList />} />
<Route path=":productId" element={<ProductDetail />} />
</Routes>
);
};
Three properties make the fix hold:
- Explicit route registration between shell and micro frontends
- Proper loading states that don’t interfere with routing
- Comprehensive monitoring of route resolution in production
Development and Testing Strategies
Local Development Setup
// scripts/dev-all.js - Script to run all micro frontends locally
const { spawn } = require('child_process');
const path = require('path');
const services = [
{ name: 'shell', port: 3000, path: './apps/shell' },
{ name: 'products', port: 3001, path: './apps/products' },
{ name: 'cart', port: 3002, path: './apps/cart' },
{ name: 'user', port: 3003, path: './apps/user' },
];
const processes = [];
services.forEach(service => {
console.log(`Starting ${service.name} on port ${service.port}...`);
// Note: do not name this `process` - it would shadow the global you read below
const child = spawn('npm', ['run', 'dev'], {
cwd: path.resolve(service.path),
stdio: 'inherit',
shell: true,
env: { ...process.env, PORT: service.port.toString() }
});
processes.push(child);
});
// Graceful shutdown
process.on('SIGTERM', () => {
processes.forEach(p => p.kill());
});
process.on('SIGINT', () => {
processes.forEach(p => p.kill());
process.exit(0);
});
Integration Testing
// tests/integration/micro-frontend-integration.test.ts
import { test, expect } from '@playwright/test';
test.describe('Micro Frontend Integration', () => {
test('should load all micro frontends correctly', async ({ page }) => {
await page.goto('http://localhost:3000');
// Wait for shell to load
await expect(page.locator('[data-testid="shell-loaded"]')).toBeVisible();
// Check that micro frontends are loaded
await expect(page.locator('[data-testid="products-mf"]')).toBeVisible();
await expect(page.locator('[data-testid="user-menu-mf"]')).toBeVisible();
});
test('should handle micro frontend communication', async ({ page }) => {
await page.goto('http://localhost:3000/products');
// Click on a product
await page.click('[data-testid="product-card"]:first-child');
// Verify cart was updated
await expect(page.locator('[data-testid="cart-count"]')).toContainText('1');
// Verify notification appeared
await expect(page.locator('[data-testid="notification"]')).toContainText('added to cart');
});
test('should handle micro frontend failures gracefully', async ({ page }) => {
// Simulate network failure for one micro frontend
await page.route('**/products/remoteEntry.js', route => {
route.abort();
});
await page.goto('http://localhost:3000');
// Should show fallback UI
await expect(page.locator('[data-testid="products-fallback"]')).toBeVisible();
// Other micro frontends should still work
await expect(page.locator('[data-testid="user-menu-mf"]')).toBeVisible();
});
});
Next in the Series
Module Federation with a shared event bus and shell-owned routing is the setup worth defaulting to: teams ship independently, and no second runtime has to be operated to make that happen. Override it when the apps share nearly all of their state, when one team owns every route anyway, or when the bundle budget cannot absorb a shared-dependency graph. A modular monolith is cheaper in all three cases.
Continue to Part 3: Advanced Patterns, Performance, and Debugging for:
- Advanced state management across distributed frontends
- Performance optimization and bundle analysis techniques
- Production debugging stories and monitoring strategies
- Security patterns for cross-origin communication
- Memory leak detection and resolution
- Migration strategies from monoliths
Series Navigation
- Part 1: Architecture fundamentals
- Part 2 (Current): Implementation patterns
- Part 3: Advanced patterns & debugging
References
- Micro Frontends - Martin Fowler - Authoritative article on micro frontend patterns, routing strategies, and cross-app communication
- Module Federation Guide - module-federation.io - Official Module Federation 2.0 documentation covering setup, dynamic remotes, and runtime plugins
- ModuleFederationPlugin - webpack - Full configuration reference for webpack’s ModuleFederationPlugin
- single-spa Recommended Setup - Best practices for structuring micro frontend applications with single-spa
- micro-frontends.org - Implementation patterns and integration strategies for team-independent frontend development
Micro Frontend Architecture Guide
A 3-part comprehensive guide to micro frontend architecture, from fundamental concepts to advanced patterns and production debugging strategies.
All Posts in This Series
Related posts
Micro frontend composition strategies compared: server-side, build-time, Module Federation, and iframe, with the team preconditions that make the trade pay off.
A practical comparison of TypeScript AI SDKs for building agents: Vercel AI SDK, OpenAI Agents SDK, and AWS Bedrock, with code examples and decision frameworks.
A comparison of modern TypeScript linting and formatting tools - ESLint, Prettier, Biome, and Oxlint - with benchmarks, config examples, and migration tips.
How SOLID principles apply to modern JavaScript: practical examples with TypeScript, React hooks, and functional patterns, plus when they're overkill.
A practical guide to learning Effect incrementally and integrating it with AWS Lambda, with real code examples, common pitfalls, and production patterns.