Building Micro-frontends: Lessons from a Real Migration That Saved Our Product

15 min read2968 words

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:

  1. Extract business logic into standalone services
  2. Create new micro-frontend with Module Federation
  3. Implement feature parity with existing functionality
  4. Add comprehensive tests for the isolated domain
  5. Deploy behind feature flag for gradual rollout
  6. Switch routing from monolith to micro-frontend
  7. 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.