From Monolith to Micro-services: A Frontend Perspective

9 min read1602 words

After leading the migration of a 500,000-line React monolith to micro-frontends across 8 teams, I learned that success isn't about the technology—it's about understanding when, why, and how to make the transition. Here's the unfiltered truth about micro-frontend migration from someone who's been through the entire journey.

The Reality Check: Do You Actually Need Micro-Frontends?

Before diving into our migration story, let me save you potentially months of wasted effort. We initially considered micro-frontends for all the wrong reasons:

  • ❌ "Netflix uses them" (Your scale isn't Netflix's scale)
  • ❌ "Our build times are slow" (Fix your build pipeline first)
  • ❌ "We want to use different frameworks" (You probably don't)
  • ❌ "It's the modern approach" (Modern doesn't mean right for you)

The real reasons we eventually migrated:

  • ✅ 8 teams stepping on each other's deployments daily
  • ✅ 3-hour deployment windows due to coordination needs
  • ✅ Teams blocked for weeks waiting for other teams' changes
  • ✅ Unable to roll back features without affecting entire app

If you don't have these problems at scale, stop reading and optimize your monolith instead.

Our Migration Architecture

Here's the architecture we settled on after three failed attempts:

// architecture/micro-frontend-config.ts
export interface MicroFrontend {
  name: string;
  entry: string;
  activeWhen: (location: Location) => boolean;
  team: string;
  dependencies: string[];
}
 
export const microFrontends: MicroFrontend[] = [
  {
    name: '@company/shell',
    entry: 'https://shell.company.com/main.js',
    activeWhen: () => true, // Always active - provides layout
    team: 'platform',
    dependencies: ['react', 'react-dom']
  },
  {
    name: '@company/products',
    entry: 'https://products.company.com/remoteEntry.js',
    activeWhen: (location) => location.pathname.startsWith('/products'),
    team: 'products',
    dependencies: ['react', 'react-dom', '@company/design-system']
  },
  {
    name: '@company/checkout',
    entry: 'https://checkout.company.com/remoteEntry.js',
    activeWhen: (location) => location.pathname.startsWith('/checkout'),
    team: 'checkout',
    dependencies: ['react', 'react-dom', '@company/design-system']
  },
  {
    name: '@company/account',
    entry: 'https://account.company.com/remoteEntry.js',
    activeWhen: (location) => location.pathname.startsWith('/account'),
    team: 'account',
    dependencies: ['react', 'react-dom', '@company/design-system']
  }
];

Module Federation: The Game Changer

Webpack 5's Module Federation solved our biggest challenge: sharing code without duplicating bundles. Here's our production configuration:

// webpack.config.js - Shell Application
const ModuleFederationPlugin = require('webpack/lib/container/ModuleFederationPlugin');
const deps = require('./package.json').dependencies;
 
module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: 'shell',
      filename: 'remoteEntry.js',
      remotes: {
        products: 'products@[productsUrl]/remoteEntry.js',
        checkout: 'checkout@[checkoutUrl]/remoteEntry.js',
        account: 'account@[accountUrl]/remoteEntry.js'
      },
      exposes: {
        './Navigation': './src/components/Navigation',
        './Footer': './src/components/Footer',
        './AuthProvider': './src/providers/AuthProvider'
      },
      shared: {
        react: { 
          singleton: true, 
          requiredVersion: deps.react,
          eager: true 
        },
        'react-dom': { 
          singleton: true, 
          requiredVersion: deps['react-dom'],
          eager: true 
        },
        '@company/design-system': { 
          singleton: true,
          requiredVersion: deps['@company/design-system']
        }
      }
    })
  ]
};

Dynamic Remote Loading with Error Boundaries

The biggest lesson: remote modules will fail. Plan for it:

// components/RemoteComponent.tsx
import React, { Suspense, useState, useEffect } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
 
interface RemoteComponentProps {
  module: string;
  component: string;
  fallback?: React.ComponentType;
  onError?: (error: Error) => void;
  [key: string]: any;
}
 
function loadComponent(module: string, component: string) {
  return async () => {
    const container = window[module];
    
    if (!container) {
      throw new Error(`Module ${module} not loaded`);
    }
    
    // Initialize the container
    await container.init(__webpack_share_scopes__.default);
    
    const factory = await container.get(component);
    const Module = factory();
    
    return { default: Module.default || Module };
  };
}
 
export function RemoteComponent({ 
  module, 
  component, 
  fallback: FallbackComponent,
  onError,
  ...props 
}: RemoteComponentProps) {
  const [Component, setComponent] = useState<React.ComponentType | null>(null);
  
  useEffect(() => {
    const loadRemoteComponent = async () => {
      try {
        const loaded = React.lazy(loadComponent(module, component));
        setComponent(() => loaded);
      } catch (error) {
        console.error(`Failed to load ${module}/${component}:`, error);
        onError?.(error);
      }
    };
    
    loadRemoteComponent();
  }, [module, component]);
  
  if (!Component) {
    return FallbackComponent ? <FallbackComponent {...props} /> : null;
  }
  
  return (
    <ErrorBoundary
      FallbackComponent={({ error }) => (
        <div className="error-container">
          <h3>Failed to load {component}</h3>
          <p>{error.message}</p>
          {FallbackComponent && <FallbackComponent {...props} />}
        </div>
      )}
      onError={onError}
    >
      <Suspense fallback={<div>Loading {component}...</div>}>
        <Component {...props} />
      </Suspense>
    </ErrorBoundary>
  );
}

Cross-Micro-Frontend Communication

We tried event buses, shared state, and pub/sub. Here's what actually worked:

// lib/micro-frontend-bus.ts
type EventCallback = (data: any) => void;
 
class MicroFrontendBus {
  private events: Map<string, Set<EventCallback>> = new Map();
  private lastEventData: Map<string, any> = new Map();
  
  emit(event: string, data: any) {
    // Store last event data for late subscribers
    this.lastEventData.set(event, data);
    
    // Notify all subscribers
    const callbacks = this.events.get(event);
    if (callbacks) {
      callbacks.forEach(callback => {
        try {
          callback(data);
        } catch (error) {
          console.error(`Error in event handler for ${event}:`, error);
        }
      });
    }
    
    // Also dispatch a custom event for cross-window communication
    window.dispatchEvent(new CustomEvent(`mfe:${event}`, { detail: data }));
  }
  
  on(event: string, callback: EventCallback): () => void {
    if (!this.events.has(event)) {
      this.events.set(event, new Set());
    }
    
    this.events.get(event)!.add(callback);
    
    // If event was previously emitted, call callback with last data
    if (this.lastEventData.has(event)) {
      callback(this.lastEventData.get(event));
    }
    
    // Return unsubscribe function
    return () => {
      const callbacks = this.events.get(event);
      if (callbacks) {
        callbacks.delete(callback);
      }
    };
  }
  
  once(event: string, callback: EventCallback) {
    const unsubscribe = this.on(event, (data) => {
      callback(data);
      unsubscribe();
    });
  }
}
 
// Singleton instance
export const mfeBus = new MicroFrontendBus();
 
// React hook for easy usage
export function useMFEEvent(event: string, handler: EventCallback) {
  useEffect(() => {
    return mfeBus.on(event, handler);
  }, [event, handler]);
}

Shared State Without Chaos

After trying Redux, MobX, and Zustand across micro-frontends, we built a simple shared state solution:

// lib/shared-state.ts
interface SharedState {
  user: User | null;
  cart: CartItem[];
  preferences: UserPreferences;
}
 
class SharedStateManager {
  private state: SharedState = {
    user: null,
    cart: [],
    preferences: {}
  };
  
  private subscribers: Map<keyof SharedState, Set<Function>> = new Map();
  
  get<K extends keyof SharedState>(key: K): SharedState[K] {
    return this.state[key];
  }
  
  set<K extends keyof SharedState>(key: K, value: SharedState[K]) {
    this.state[key] = value;
    this.notify(key);
    
    // Persist to sessionStorage for cross-MFE consistency
    sessionStorage.setItem(`mfe-state-${key}`, JSON.stringify(value));
  }
  
  subscribe<K extends keyof SharedState>(
    key: K, 
    callback: (value: SharedState[K]) => void
  ): () => void {
    if (!this.subscribers.has(key)) {
      this.subscribers.set(key, new Set());
    }
    
    this.subscribers.get(key)!.add(callback);
    
    // Call immediately with current value
    callback(this.state[key]);
    
    return () => {
      this.subscribers.get(key)?.delete(callback);
    };
  }
  
  private notify<K extends keyof SharedState>(key: K) {
    const callbacks = this.subscribers.get(key);
    if (callbacks) {
      callbacks.forEach(callback => callback(this.state[key]));
    }
  }
  
  hydrate() {
    // Restore state from sessionStorage on initialization
    Object.keys(this.state).forEach(key => {
      const stored = sessionStorage.getItem(`mfe-state-${key}`);
      if (stored) {
        try {
          this.state[key] = JSON.parse(stored);
        } catch (e) {
          console.error(`Failed to hydrate ${key}:`, e);
        }
      }
    });
  }
}
 
export const sharedState = new SharedStateManager();
 
// React hook
export function useSharedState<K extends keyof SharedState>(key: K) {
  const [value, setValue] = useState(() => sharedState.get(key));
  
  useEffect(() => {
    return sharedState.subscribe(key, setValue);
  }, [key]);
  
  const updateValue = useCallback((newValue: SharedState[K]) => {
    sharedState.set(key, newValue);
  }, [key]);
  
  return [value, updateValue] as const;
}

The Migration Strategy That Actually Worked

After two failed "big bang" attempts, here's the incremental approach that succeeded:

Phase 1: Modularize the Monolith (2 months)

// Before
src/
  components/
    ProductList.tsx
    Checkout.tsx
    UserProfile.tsx
    Navigation.tsx
 
// After  
src/
  modules/
    products/
      components/
      services/
      hooks/
    checkout/
      components/
      services/
      hooks/
    shared/
      components/
      services/

Phase 2: Extract Non-Critical Features (1 month)

We started with the admin dashboard—low traffic, single team:

// Initial extraction
new ModuleFederationPlugin({
  name: 'admin',
  exposes: {
    './Dashboard': './src/modules/admin/Dashboard'
  }
});

Phase 3: Gradual Feature Migration (6 months)

One feature at a time, with feature flags for rollback:

// feature-flags.ts
export function useFeatureFlag(flag: string): boolean {
  const [enabled, setEnabled] = useState(false);
  
  useEffect(() => {
    fetch(`/api/features/${flag}`)
      .then(res => res.json())
      .then(data => setEnabled(data.enabled))
      .catch(() => setEnabled(false));
  }, [flag]);
  
  return enabled;
}
 
// Usage
function App() {
  const useMicroFrontend = useFeatureFlag('use-mfe-checkout');
  
  return useMicroFrontend 
    ? <RemoteComponent module="checkout" component="CheckoutFlow" />
    : <LegacyCheckout />;
}

Deployment and CI/CD

Each micro-frontend has its own pipeline:

# .github/workflows/deploy-mfe.yml
name: Deploy Micro-Frontend
on:
  push:
    branches: [main]
    paths:
      - 'apps/products/**'
 
jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
      
      - name: Build
        run: |
          cd apps/products
          npm ci
          npm run build
          
      - name: Run Integration Tests
        run: |
          npm run test:integration
          
      - name: Deploy to CDN
        run: |
          aws s3 sync dist/ s3://mfe-products/ --delete
          aws cloudfront create-invalidation --distribution-id $DIST_ID --paths "/*"
          
      - name: Update Module Registry
        run: |
          curl -X POST https://api.company.com/mfe/register \
            -H "Authorization: Bearer $API_TOKEN" \
            -d '{
              "name": "products",
              "version": "${{ github.sha }}",
              "url": "https://cdn.company.com/products/remoteEntry.js"
            }'

Performance Optimization Strategies

Micro-frontends can hurt performance if not done right:

// lib/performance-monitor.ts
class MFEPerformanceMonitor {
  private metrics: Map<string, PerformanceEntry[]> = new Map();
  
  measureLoad(mfeName: string): () => void {
    const startMark = `mfe-${mfeName}-start`;
    const endMark = `mfe-${mfeName}-end`;
    
    performance.mark(startMark);
    
    return () => {
      performance.mark(endMark);
      performance.measure(mfeName, startMark, endMark);
      
      const measure = performance.getEntriesByName(mfeName)[0];
      this.recordMetric(mfeName, measure);
      
      // Send to monitoring
      this.sendToMonitoring({
        mfe: mfeName,
        loadTime: measure.duration,
        timestamp: Date.now()
      });
    };
  }
  
  private recordMetric(mfeName: string, entry: PerformanceEntry) {
    if (!this.metrics.has(mfeName)) {
      this.metrics.set(mfeName, []);
    }
    this.metrics.get(mfeName)!.push(entry);
  }
  
  getAverageLoadTime(mfeName: string): number {
    const entries = this.metrics.get(mfeName) || [];
    if (entries.length === 0) return 0;
    
    const total = entries.reduce((sum, entry) => sum + entry.duration, 0);
    return total / entries.length;
  }
}

Lessons Learned: The Hard Truth

  1. Micro-frontends are an organizational solution, not a technical one. If your teams aren't truly independent, you're just adding complexity.

  2. Start with a design system. Without shared components, you'll have 8 different button styles in 6 months.

  3. Version management is hell. We spent 30% of our time dealing with dependency conflicts.

  4. Testing becomes exponentially harder. Integration tests across micro-frontends are complex and slow.

  5. Performance will initially degrade. We saw a 20% increase in load time before optimization brought it back down.

  6. You need senior engineers. Junior developers struggled with the distributed complexity.

  7. Rollback strategy is critical. We had three production incidents before getting this right.

After 18 months, we achieved our goals: teams deploy independently 10+ times per day, deployment time dropped from 3 hours to 15 minutes, and team velocity increased by 40%. But it was harder than expected, took longer than planned, and cost more than budgeted.

Would I do it again? Only if facing the same organizational scaling challenges. For 90% of applications, a well-architected monolith with clear module boundaries is the better choice.