Skip to content

Micro Frontend Implementation Patterns: Module Federation and Beyond

Production-ready Module Federation configurations, cross-micro frontend communication, routing strategies, and practical implementation patterns with real debugging examples.

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: This post builds on concepts from Part 1. If you're new to micro frontends, start there first.


In Part 1, we explored the fundamental architectural patterns for micro frontends. Now we'll dive deep into practical implementation, focusing on Module Federation as the dominant runtime integration approach, along with real-world communication patterns and debugging strategies I've encountered in production systems.

Module Federation Deep Dive

Module Federation, introduced in Webpack 5, has become the gold standard for runtime micro frontend integration. Unlike simple dynamic imports, it provides sophisticated dependency sharing, version management, and runtime composition capabilities.

Setting Up a Production-Ready Module Federation System

Let's build a realistic e-commerce application with separate teams owning different domains:

typescript
// apps/shell/webpack.config.js// Note: Using stable versions for production reliability rather than bleeding edge.// @module-federation/enhanced is the newer package (previously @module-federation/webpack)const ModuleFederationPlugin = require('@module-federation/enhanced');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': '*',    },  },};
typescript
// apps/products/webpack.config.jsconst ModuleFederationPlugin = require('@module-federation/enhanced');
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. Here's a production-tested approach:

typescript
// src/components/MicroFrontendLoader.tsximport 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. Here are the patterns I've found most effective:

1. Event-Driven Communication

typescript
// @company/event-bus - Shared event bus packageinterface 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 usageexport 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

typescript
// products/src/components/ProductList.tsximport 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>  );};
typescript
// cart/src/components/CartButton.tsximport 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

typescript
// shell/src/App.tsximport React, { Suspense } from 'react';import { BrowserRouter, Routes, Route, Navigate } 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>          </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

typescript
// products/src/ProductsApp.tsximport React 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>  );};

A Debugging Story: The Mystery of the Disappearing Routes

Here's a real debugging challenge that illustrates the complexity of micro frontend routing. We had a production issue where certain product detail pages would randomly show a 404 error, but only for some users and only sometimes.

The investigation revealed a race condition in our routing setup:

typescript
// The problematic codeconst ProductsApp: React.FC = () => {  const [isLoaded, setIsLoaded] = useState(false);
  useEffect(() => {    // This was causing the race condition    setTimeout(() => setIsLoaded(true), 100);  }, []);
  if (!isLoaded) {    return <div>Loading...</div>;  }
  return (    <Routes>      <Route path=":productId" element={<ProductDetail />} />    </Routes>  );};

The issue was that React Router in the shell was trying to match routes before the micro frontend had finished initializing its own routes. Users with faster connections would hit the race condition more often.

The solution required coordination between shell and micro frontend routing:

typescript
// Fixed version with proper route registrationimport { 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>  );};

This experience taught us the importance of:

  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

typescript
// scripts/dev-all.js - Script to run all micro frontends locallyconst { 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}...`);
  const process = spawn('npm', ['run', 'dev'], {    cwd: path.resolve(service.path),    stdio: 'inherit',    shell: true,    env: { ...process.env, PORT: service.port.toString() }  });
  processes.push(process);});
// Graceful shutdownprocess.on('SIGTERM', () => {  processes.forEach(p => p.kill());});
process.on('SIGINT', () => {  processes.forEach(p => p.kill());  process.exit(0);});

Integration Testing

typescript
// tests/integration/micro-frontend-integration.test.tsimport { test, expect, Page } 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();  });});

What's Coming Next

You now have the foundation for implementing micro frontends with Module Federation, along with robust communication and routing patterns. But production systems require even more sophisticated approaches.

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

Key Insight: The patterns covered here work well for most use cases, but the real challenges emerge when dealing with scale, complex state interactions, and the inevitable edge cases in production environments.

The debugging stories shared represent common issues you'll encounter. Every micro frontend system brings unique complexities, but understanding these foundational patterns will help you navigate them more effectively.


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.

Progress2/3 posts completed

Related Posts