Skip to content
Ayhan Sipahi Ayhan Sipahi

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:

  1. Explicit route registration between shell and micro frontends
  2. Proper loading states that don’t interfere with routing
  3. 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 Frontend Architecture Guide

A 3-part comprehensive guide to micro frontend architecture, from fundamental concepts to advanced patterns and production debugging strategies.

Progress 2/3 posts completed

All Posts in This Series

Related posts