Building Micro-frontends: Lessons from a Real Migration That Saved Our Product
Eight months ago, our engineering team faced a crisis. Our React monolith had grown to 200,000 lines of code, deployments took 45 minutes, and four different teams were constantly blocking each other's releases. A single bug fix required a full application deployment, and our productivity had ground to a halt.
Today, we deploy independently 15+ times per day, our largest team can ship features without waiting for others, and our build times are under 3 minutes. This transformation happened through a carefully planned migration to micro-frontends using Webpack 5's Module Federation.
I want to share the real lessons from our migration—not just the theoretical benefits you read about, but the actual problems we encountered and how we solved them.
The Monolith Problem: Why We Had to Change
Our application started as a simple dashboard for managing customer data. Over two years, it grew into a full platform handling billing, analytics, user management, and reporting. By 2024, the codebase had become unmanageable:
// Our monolithic structure looked like this
src/
├── components/ // 150+ shared components
│ ├── billing/ // Billing team components
│ ├── analytics/ // Analytics team components
│ ├── users/ // User management components
│ └── shared/ // "Shared" components used everywhere
├── pages/ // 40+ page components
├── services/ // API services for all domains
├── utils/ // Utility functions for all teams
└── hooks/ // Custom hooks used across domains
The pain points we experienced:
- Deployment bottleneck: Every change required a full build and deployment
- Team coordination overhead: Weekly "deployment planning" meetings
- Testing complexity: End-to-end tests took 2 hours to run
- Technology lock-in: Impossible to upgrade React versions safely
- Code ownership confusion: Nobody knew who owned which parts
The breaking point came when a critical billing bug fix was delayed 3 days because of conflicts with an analytics feature launch.
Planning the Migration: Domain Boundaries Matter More Than Code
The biggest mistake teams make is splitting micro-frontends by technical concerns instead of business domains. We learned this the hard way.
Our Initial (Wrong) Approach
// ❌ Don't split by technical layers
micro-frontends/
├── components-mf/ // All shared components
├── services-mf/ // All API services
├── pages-mf/ // All page components
└── utils-mf/ // All utility functions
This approach failed because it created dependencies between every micro-frontend. Deploying the components micro-frontend affected all other applications.
Our Final (Correct) Approach
// ✅ Split by business domains
micro-frontends/
├── shell/ // Navigation, routing, shared layout
├── billing/ // Complete billing domain
├── analytics/ // Complete analytics domain
├── user-management/ // Complete user domain
└── shared-design/ // Design system only
Each micro-frontend became a complete vertical slice of functionality, owned by one team.
Technical Architecture: Module Federation Implementation
After evaluating Single-spa, Web Components, and iframes, we chose Webpack 5's Module Federation because it provided the best balance of independence and performance.
Shell Application Architecture
The shell acts as the orchestrator, handling routing and shared concerns:
// apps/shell/webpack.config.js
const ModuleFederationPlugin = require('@module-federation/webpack');
module.exports = {
entry: './src/index.tsx',
resolve: {
extensions: ['.tsx', '.ts', '.js', '.jsx'],
},
plugins: [
new ModuleFederationPlugin({
name: 'shell',
remotes: {
billing: 'billing@http://localhost:3001/remoteEntry.js',
analytics: 'analytics@http://localhost:3002/remoteEntry.js',
userManagement: 'userManagement@http://localhost:3003/remoteEntry.js',
},
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' },
'@emotion/react': { singleton: true },
'@company/design-system': { singleton: true, requiredVersion: '^2.1.0' },
},
}),
],
};
// apps/shell/src/App.tsx
import React, { Suspense } from 'react';
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { ErrorBoundary } from 'react-error-boundary';
import { DesignSystemProvider } from '@company/design-system';
import Layout from './components/Layout';
import LoadingSpinner from './components/LoadingSpinner';
import ErrorFallback from './components/ErrorFallback';
// Lazy load micro-frontends
const BillingApp = React.lazy(() => import('billing/App'));
const AnalyticsApp = React.lazy(() => import('analytics/App'));
const UserManagementApp = React.lazy(() => import('userManagement/App'));
export default function App() {
return (
<DesignSystemProvider>
<BrowserRouter>
<Layout>
<ErrorBoundary FallbackComponent={ErrorFallback}>
<Suspense fallback={<LoadingSpinner />}>
<Routes>
<Route path="/" element={<Navigate to="/billing" replace />} />
<Route path="/billing/*" element={<BillingApp />} />
<Route path="/analytics/*" element={<AnalyticsApp />} />
<Route path="/users/*" element={<UserManagementApp />} />
</Routes>
</Suspense>
</ErrorBoundary>
</Layout>
</BrowserRouter>
</DesignSystemProvider>
);
}
Micro-frontend Configuration
Each micro-frontend exposes its main component:
// apps/billing/webpack.config.js
const ModuleFederationPlugin = require('@module-federation/webpack');
module.exports = {
entry: './src/bootstrap.tsx',
plugins: [
new ModuleFederationPlugin({
name: 'billing',
filename: 'remoteEntry.js',
exposes: {
'./App': './src/App',
},
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' },
'@emotion/react': { singleton: true },
'@company/design-system': { singleton: true, requiredVersion: '^2.1.0' },
},
}),
],
};
// apps/billing/src/App.tsx
import React from 'react';
import { Routes, Route } from 'react-router-dom';
import { BillingProvider } from './context/BillingContext';
import Dashboard from './pages/Dashboard';
import Invoices from './pages/Invoices';
import PaymentMethods from './pages/PaymentMethods';
export default function BillingApp() {
return (
<BillingProvider>
<Routes>
<Route index element={<Dashboard />} />
<Route path="invoices" element={<Invoices />} />
<Route path="payment-methods" element={<PaymentMethods />} />
</Routes>
</BillingProvider>
);
}
Independent Development Setup
Each micro-frontend can run standalone for development:
// apps/billing/src/bootstrap.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import { DesignSystemProvider } from '@company/design-system';
import App from './App';
// Mount function for standalone development
const mount = (element: HTMLElement, options: { basePath?: string } = {}) => {
const { basePath = '/billing' } = options;
const root = ReactDOM.createRoot(element);
root.render(
<DesignSystemProvider>
<BrowserRouter basename={basePath}>
<App />
</BrowserRouter>
</DesignSystemProvider>
);
return root;
};
// Standalone development
if (process.env.NODE_ENV === 'development' && !window.__POWERED_BY_SHELL__) {
const devRoot = document.getElementById('root');
if (devRoot) {
mount(devRoot);
}
}
export { mount };
State Management: Avoiding the Global State Trap
One of the hardest problems in micro-frontends is state management. We learned to embrace isolation and use events for communication.
Local State Management
Each micro-frontend manages its own state:
// apps/billing/src/context/BillingContext.tsx
import React, { createContext, useContext, useReducer, useEffect } from 'react';
import { billingApi } from '../services/api';
interface BillingState {
invoices: Invoice[];
paymentMethods: PaymentMethod[];
loading: boolean;
error: string | null;
}
interface BillingContextType {
state: BillingState;
actions: {
fetchInvoices: () => Promise<void>;
createInvoice: (data: CreateInvoiceData) => Promise<void>;
updatePaymentMethod: (id: string, data: UpdatePaymentMethodData) => Promise<void>;
};
}
const BillingContext = createContext<BillingContextType | null>(null);
function billingReducer(state: BillingState, action: any): BillingState {
switch (action.type) {
case 'SET_LOADING':
return { ...state, loading: action.payload };
case 'SET_INVOICES':
return { ...state, invoices: action.payload, loading: false };
case 'SET_ERROR':
return { ...state, error: action.payload, loading: false };
default:
return state;
}
}
export function BillingProvider({ children }: { children: React.ReactNode }) {
const [state, dispatch] = useReducer(billingReducer, {
invoices: [],
paymentMethods: [],
loading: false,
error: null,
});
const actions = {
fetchInvoices: async () => {
dispatch({ type: 'SET_LOADING', payload: true });
try {
const invoices = await billingApi.getInvoices();
dispatch({ type: 'SET_INVOICES', payload: invoices });
} catch (error) {
dispatch({ type: 'SET_ERROR', payload: error.message });
}
},
// ... other actions
};
return (
<BillingContext.Provider value={{ state, actions }}>
{children}
</BillingContext.Provider>
);
}
export const useBilling = () => {
const context = useContext(BillingContext);
if (!context) {
throw new Error('useBilling must be used within BillingProvider');
}
return context;
};
Cross-Application Communication
For the few cases where micro-frontends need to communicate, we use a custom event system:
// shared/src/events.ts
export enum AppEvents {
USER_UPDATED = 'app:user:updated',
BILLING_STATUS_CHANGED = 'app:billing:status:changed',
NAVIGATION_REQUESTED = 'app:navigation:requested',
}
export interface EventData {
[AppEvents.USER_UPDATED]: {
userId: string;
userData: Partial<User>;
};
[AppEvents.BILLING_STATUS_CHANGED]: {
userId: string;
status: 'active' | 'suspended' | 'cancelled';
};
[AppEvents.NAVIGATION_REQUESTED]: {
path: string;
data?: Record<string, any>;
};
}
class EventBus {
private listeners: Map<string, Set<Function>> = new Map();
emit<T extends AppEvents>(event: T, data: EventData[T]) {
const eventListeners = this.listeners.get(event);
if (eventListeners) {
eventListeners.forEach(listener => {
try {
listener(data);
} catch (error) {
console.error(`Error in event listener for ${event}:`, error);
}
});
}
}
on<T extends AppEvents>(event: T, listener: (data: EventData[T]) => void) {
if (!this.listeners.has(event)) {
this.listeners.set(event, new Set());
}
this.listeners.get(event)!.add(listener);
return () => {
this.listeners.get(event)?.delete(listener);
};
}
}
export const eventBus = new EventBus();
// Usage in billing micro-frontend
import { eventBus, AppEvents } from '@company/shared';
// Emit event when billing status changes
const handleStatusUpdate = async (userId: string, newStatus: BillingStatus) => {
await billingApi.updateStatus(userId, newStatus);
eventBus.emit(AppEvents.BILLING_STATUS_CHANGED, {
userId,
status: newStatus,
});
};
// Listen for user updates
useEffect(() => {
const unsubscribe = eventBus.on(AppEvents.USER_UPDATED, (data) => {
// Update billing info when user data changes
if (data.userData.email) {
updateBillingEmail(data.userId, data.userData.email);
}
});
return unsubscribe;
}, []);
Migration Strategy: The Strangler Fig Pattern
We used the Strangler Fig pattern to migrate incrementally without disrupting the business:
Phase 1: Extract Shared Design System
// packages/design-system/src/index.ts
export { Button } from './components/Button';
export { Input } from './components/Input';
export { Modal } from './components/Modal';
export { DesignSystemProvider } from './provider/DesignSystemProvider';
export * from './tokens';
export * from './types';
This gave us a stable foundation and prevented UI inconsistencies during migration.
Phase 2: Build Shell Application
We created the shell with minimal routing and gradually moved sections:
// Initial shell with fallback to monolith
export default function App() {
return (
<DesignSystemProvider>
<BrowserRouter>
<Layout>
<Routes>
{/* New micro-frontends */}
<Route path="/billing/*" element={<BillingApp />} />
{/* Fallback to monolith for unmigrated routes */}
<Route path="/*" element={<LegacyApp />} />
</Routes>
</Layout>
</BrowserRouter>
</DesignSystemProvider>
);
}
Phase 3: Migrate Domain by Domain
Each domain migration followed this pattern:
- Extract business logic into standalone services
- Create new micro-frontend with Module Federation
- Implement feature parity with existing functionality
- Add comprehensive tests for the isolated domain
- Deploy behind feature flag for gradual rollout
- Switch routing from monolith to micro-frontend
- Remove old code after stability validation
Testing Strategy: Isolation and Integration
Testing micro-frontends requires a different approach than monolithic applications:
Unit Testing (Per Micro-frontend)
// apps/billing/src/components/__tests__/InvoiceList.test.tsx
import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { BillingProvider } from '../../context/BillingContext';
import { DesignSystemProvider } from '@company/design-system';
import InvoiceList from '../InvoiceList';
const renderWithProviders = (component: React.ReactElement) => {
return render(
<DesignSystemProvider>
<BillingProvider>
{component}
</BillingProvider>
</DesignSystemProvider>
);
};
describe('InvoiceList', () => {
it('displays invoices correctly', async () => {
const mockInvoices = [
{ id: '1', amount: 100, status: 'paid', dueDate: '2025-06-01' },
{ id: '2', amount: 200, status: 'pending', dueDate: '2025-06-15' },
];
renderWithProviders(<InvoiceList invoices={mockInvoices} />);
expect(screen.getByText('$100.00')).toBeInTheDocument();
expect(screen.getByText('$200.00')).toBeInTheDocument();
expect(screen.getByText('Paid')).toBeInTheDocument();
expect(screen.getByText('Pending')).toBeInTheDocument();
});
it('handles invoice actions correctly', async () => {
const user = userEvent.setup();
const mockOnUpdate = jest.fn();
renderWithProviders(
<InvoiceList invoices={[]} onInvoiceUpdate={mockOnUpdate} />
);
const createButton = screen.getByText('Create Invoice');
await user.click(createButton);
expect(mockOnUpdate).toHaveBeenCalledWith({ action: 'create' });
});
});
Contract Testing Between Micro-frontends
// shared/src/contracts/__tests__/billing-events.contract.test.ts
import { eventBus, AppEvents } from '../events';
describe('Billing Events Contract', () => {
it('should emit billing status change with correct data structure', () => {
const mockListener = jest.fn();
eventBus.on(AppEvents.BILLING_STATUS_CHANGED, mockListener);
const testData = {
userId: 'user-123',
status: 'active' as const,
};
eventBus.emit(AppEvents.BILLING_STATUS_CHANGED, testData);
expect(mockListener).toHaveBeenCalledWith({
userId: 'user-123',
status: 'active',
});
});
it('should validate event data types at runtime', () => {
expect(() => {
eventBus.emit(AppEvents.BILLING_STATUS_CHANGED, {
userId: 'user-123',
status: 'invalid-status' as any,
});
}).not.toThrow(); // Events should be tolerant but logged
});
});
End-to-End Testing
// e2e/tests/billing-flow.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Billing Flow', () => {
test('user can create and pay invoice', async ({ page }) => {
await page.goto('/billing');
// Test micro-frontend loads
await expect(page.locator('[data-testid="billing-dashboard"]')).toBeVisible();
// Create invoice
await page.click('[data-testid="create-invoice-button"]');
await page.fill('[data-testid="invoice-amount"]', '150.00');
await page.fill('[data-testid="invoice-description"]', 'Test Invoice');
await page.click('[data-testid="save-invoice"]');
// Verify invoice appears in list
await expect(page.locator('text=$150.00')).toBeVisible();
await expect(page.locator('text=Test Invoice')).toBeVisible();
// Test navigation to different micro-frontend
await page.click('[data-testid="nav-analytics"]');
await expect(page.locator('[data-testid="analytics-dashboard"]')).toBeVisible();
});
});
Deployment and CI/CD: Independent Pipelines
Each micro-frontend has its own deployment pipeline:
# .github/workflows/billing-deploy.yml
name: Deploy Billing Micro-frontend
on:
push:
branches: [main]
paths: ['apps/billing/**', 'packages/shared/**']
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '18'
cache: 'npm'
- run: npm ci
- run: npm run test:billing
- run: npm run build:billing
deploy:
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '18'
cache: 'npm'
- run: npm ci
- run: npm run build:billing
- name: Deploy to CDN
run: |
aws s3 sync apps/billing/dist s3://${{ secrets.BILLING_BUCKET }}/
aws cloudfront create-invalidation --distribution-id ${{ secrets.BILLING_CDN_ID }} --paths "/*"
- name: Update service registry
run: |
curl -X POST "${{ secrets.SERVICE_REGISTRY_URL }}/update" \
-H "Authorization: Bearer ${{ secrets.REGISTRY_TOKEN }}" \
-d '{
"name": "billing",
"version": "${{ github.sha }}",
"url": "https://billing-cdn.company.com/remoteEntry.js"
}'
Environment-specific Configuration
// apps/shell/src/config/remote-apps.ts
interface RemoteApp {
name: string;
url: string;
scope: string;
}
const getRemoteApps = (): Record<string, RemoteApp> => {
const env = process.env.NODE_ENV;
switch (env) {
case 'development':
return {
billing: {
name: 'billing',
url: 'http://localhost:3001/remoteEntry.js',
scope: 'billing',
},
analytics: {
name: 'analytics',
url: 'http://localhost:3002/remoteEntry.js',
scope: 'analytics',
},
};
case 'production':
return {
billing: {
name: 'billing',
url: 'https://billing-cdn.company.com/remoteEntry.js',
scope: 'billing',
},
analytics: {
name: 'analytics',
url: 'https://analytics-cdn.company.com/remoteEntry.js',
scope: 'analytics',
},
};
default:
throw new Error(`Unknown environment: ${env}`);
}
};
export const remoteApps = getRemoteApps();
Performance Optimization: What We Learned
Bundle Size Management
Module Federation's shared dependencies were crucial for avoiding duplication:
// webpack.config.js - Shared dependencies configuration
shared: {
react: {
singleton: true,
requiredVersion: '^18.2.0',
eager: false // Lazy load to avoid blocking
},
'react-dom': {
singleton: true,
requiredVersion: '^18.2.0',
eager: false
},
'@company/design-system': {
singleton: true,
requiredVersion: '^2.1.0',
eager: true // Load immediately for consistent UI
},
lodash: {
singleton: false, // Allow different versions
requiredVersion: '^4.17.0'
}
}
Lazy Loading Strategy
// Shell app - Smart lazy loading
const BillingApp = React.lazy(() =>
import('billing/App').catch(() => ({
default: () => <ErrorBoundary message="Billing service unavailable" />
}))
);
const AnalyticsApp = React.lazy(() =>
import('analytics/App').catch(() => ({
default: () => <ErrorBoundary message="Analytics service unavailable" />
}))
);
// Preload on hover for better UX
const handleNavHover = (appName: string) => {
if (appName === 'billing') {
import('billing/App');
} else if (appName === 'analytics') {
import('analytics/App');
}
};
Monitoring and Observability
// shared/src/monitoring.ts
import { performance } from '@company/monitoring';
export const trackMicroFrontendLoad = (name: string) => {
const startTime = performance.now();
return {
end: () => {
const loadTime = performance.now() - startTime;
performance.track('microfrontend.load', {
name,
loadTime,
timestamp: Date.now(),
});
}
};
};
export const trackMicroFrontendError = (name: string, error: Error) => {
performance.track('microfrontend.error', {
name,
error: error.message,
stack: error.stack,
timestamp: Date.now(),
});
};
Real Results: Measuring Success
After 6 months in production, here are our measurable improvements:
Development Velocity
- Deployment frequency: From 2-3 times per week to 15+ times per day
- Lead time: From 3-5 days to 4-6 hours
- Build time: From 45 minutes to 3 minutes average
- Team velocity: 40% increase in story points delivered
System Reliability
- Production incidents: 60% reduction in deployment-related issues
- Mean time to recovery: From 2 hours to 15 minutes
- Rollback capability: From impossible to instant per micro-frontend
Developer Experience
- Cross-team blocking: 80% reduction
- Onboarding time: From 2 weeks to 3 days for new developers
- Technology adoption: Teams can now upgrade independently
Common Pitfalls and How We Avoided Them
1. Over-fragmenting the Architecture
Problem: Initially, we created too many small micro-frontends, leading to complexity overhead.
Solution: We merged related functionality and established a minimum team size rule—each micro-frontend must be owned by at least 2-3 developers.
2. Inconsistent User Experience
Problem: Different teams implemented similar components differently, causing UX inconsistencies.
Solution: We invested heavily in our design system and added CI checks to prevent design system violations.
// CI check for design system compliance
// scripts/check-design-compliance.js
const fs = require('fs');
const path = require('path');
const checkDesignCompliance = (microfrontendPath) => {
const files = getAllTypeScriptFiles(microfrontendPath);
const violations = [];
files.forEach(file => {
const content = fs.readFileSync(file, 'utf8');
// Check for hardcoded colors
if (content.includes('#') && content.match(/#[0-9a-fA-F]{3,6}/)) {
violations.push(`Hardcoded color in ${file}`);
}
// Check for hardcoded spacing
if (content.match(/padding:\s*\d+px|margin:\s*\d+px/)) {
violations.push(`Hardcoded spacing in ${file}`);
}
});
return violations;
};
3. Dependency Hell
Problem: Different micro-frontends using incompatible versions of shared libraries.
Solution: We established strict versioning policies and automated dependency updates.
4. Testing Complexity
Problem: End-to-end testing became complicated across multiple micro-frontends.
Solution: We invested in contract testing and built a comprehensive test environment that could run all micro-frontends together.
Future Considerations: What We're Planning Next
1. Edge-Side Includes (ESI)
We're exploring server-side composition for better SEO and initial page load performance.
2. Micro-frontend Orchestration Platform
Building internal tooling to manage micro-frontend versions, deployments, and monitoring from a single dashboard.
3. Advanced State Management
Investigating event sourcing patterns for complex cross-micro-frontend workflows.
Should You Migrate to Micro-frontends?
Micro-frontends aren't a silver bullet. Based on our experience, consider this architecture if you have:
✅ Good candidates:
- Multiple teams (3+) working on the same application
- Different parts of your application evolving at different speeds
- Need for independent deployment and scaling
- Large, complex applications with clear business domain boundaries
- Teams with different technology preferences or upgrade cycles
❌ Poor candidates:
- Small teams (1-2 developers total)
- Simple applications with tightly coupled functionality
- Limited infrastructure or CI/CD capabilities
- Teams lacking experience with complex architectural patterns
The migration to micro-frontends saved our product development velocity and enabled our teams to work independently. However, it required significant architectural discipline, investment in tooling, and a commitment to maintaining consistency across autonomous teams.
The key lesson: micro-frontends are an organizational solution as much as a technical one. If your teams can't work independently in other aspects of development, micro-frontends won't magically solve your collaboration problems.
But if you're facing the same scaling challenges we were, and you have the organizational maturity to support distributed development, micro-frontends can transform your development experience. The investment in proper architecture, tooling, and processes pays dividends in team velocity and system maintainability.