DEV Community

Cover image for Module Federation: Building Scalable Micro-Frontends for Modern Web Applications
Nithin Bharadwaj
Nithin Bharadwaj

Posted on

Module Federation: Building Scalable Micro-Frontends for Modern Web Applications

As a best-selling author, I invite you to explore my books on Amazon. Don't forget to follow me on Medium and show your support. Thank you! Your support means the world!

Modern web development continues to evolve as applications grow in complexity. The micro-frontend architecture has emerged as a powerful approach for large organizations to build scalable applications while maintaining team autonomy. I've worked with this pattern extensively and find it particularly effective for complex applications with multiple development teams.

Module Federation Configuration

Module Federation represents one of the most significant advancements in micro-frontend implementation. By leveraging Webpack 5's capabilities, we can share components and logic across independent applications.

The core configuration includes defining exposed modules and remote dependencies:

// Host Application (webpack.config.js)
const { ModuleFederationPlugin } = require('webpack').container;

module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: 'container',
      remotes: {
        productApp: 'productApp@http://localhost:8081/remoteEntry.js',
        checkoutApp: 'checkoutApp@http://localhost:8082/remoteEntry.js'
      },
      shared: ['react', 'react-dom']
    })
  ]
};

// Remote Application (webpack.config.js)
const { ModuleFederationPlugin } = require('webpack').container;

module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: 'productApp',
      filename: 'remoteEntry.js',
      exposes: {
        './ProductList': './src/components/ProductList'
      },
      shared: ['react', 'react-dom']
    })
  ]
};
Enter fullscreen mode Exit fullscreen mode

To load these federated modules, I use dynamic imports in the host application:

// App.js in host application
import React, { lazy, Suspense } from 'react';

const ProductList = lazy(() => import('productApp/ProductList'));

export default function App() {
  return (
    <div>
      <h1>Main Container</h1>
      <Suspense fallback={<div>Loading Products...</div>}>
        <ProductList />
      </Suspense>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

The real power comes from sharing dependencies. This prevents duplicate libraries and ensures consistent versions across micro-frontends.

Cross-Application Communication

Communication between micro-frontends requires careful design to avoid tight coupling. I've found custom event buses particularly effective:

// eventBus.js
class EventBus {
  constructor() {
    this.events = {};
  }

  subscribe(event, callback) {
    if (!this.events[event]) {
      this.events[event] = [];
    }
    this.events[event].push(callback);

    return () => {
      this.events[event] = this.events[event].filter(cb => cb !== callback);
    };
  }

  publish(event, data) {
    if (!this.events[event]) return;
    this.events[event].forEach(callback => callback(data));
  }
}

// Create a singleton instance
const eventBus = new EventBus();
export default eventBus;
Enter fullscreen mode Exit fullscreen mode

This pattern allows micro-frontends to communicate without direct dependencies:

// In the ProductDetail micro-frontend
import eventBus from './eventBus';

function addToCart(product) {
  // Add product to cart
  eventBus.publish('PRODUCT_ADDED_TO_CART', product);
}

// In the Cart micro-frontend
import eventBus from './eventBus';

function CartComponent() {
  const [cartItems, setCartItems] = useState([]);

  useEffect(() => {
    const unsubscribe = eventBus.subscribe('PRODUCT_ADDED_TO_CART', (product) => {
      setCartItems(prev => [...prev, product]);
    });

    return unsubscribe;
  }, []);

  // Render cart items
}
Enter fullscreen mode Exit fullscreen mode

For larger applications, we can leverage the browser's native CustomEvent API:

// Global event bus
const eventBus = {
  dispatch(event, data) {
    document.dispatchEvent(new CustomEvent(event, { detail: data }));
  },
  subscribe(event, callback) {
    const handler = (e) => callback(e.detail);
    document.addEventListener(event, handler);
    return () => document.removeEventListener(event, handler);
  }
};
Enter fullscreen mode Exit fullscreen mode

Runtime Integration

Runtime composition is crucial for micro-frontends to work together seamlessly. I typically create a composition layer that loads and mounts components:

// compositionService.js
export async function loadMicroFrontend(name, elementId, props = {}) {
  // Define URLs for each micro-frontend
  const urls = {
    'product': 'http://localhost:8081/remoteEntry.js',
    'checkout': 'http://localhost:8082/remoteEntry.js'
  };

  if (!urls[name]) {
    throw new Error(`Unknown micro-frontend: ${name}`);
  }

  // Dynamically load the script
  await loadRemoteScript(urls[name]);

  // Get the container element
  const container = document.getElementById(elementId);
  if (!container) {
    throw new Error(`Container element not found: ${elementId}`);
  }

  // Initialize the micro-frontend
  window[name].init(container, props);

  return {
    unmount: () => {
      window[name].unmount(container);
    }
  };
}

function loadRemoteScript(url) {
  return new Promise((resolve, reject) => {
    const script = document.createElement('script');
    script.src = url;
    script.onload = resolve;
    script.onerror = reject;
    document.head.appendChild(script);
  });
}
Enter fullscreen mode Exit fullscreen mode

This approach allows the shell application to dynamically load micro-frontends as needed.

Shared Authentication

Managing authentication across micro-frontends presents unique challenges. I prefer token-based authentication stored in a secure place accessible to all micro-frontends:

// authService.js
class AuthService {
  constructor() {
    this.tokenKey = 'auth_token';
    this.expiryKey = 'auth_expiry';
  }

  setToken(token, expiresIn) {
    const expiryTime = new Date().getTime() + expiresIn * 1000;
    localStorage.setItem(this.tokenKey, token);
    localStorage.setItem(this.expiryKey, expiryTime.toString());

    // Notify other micro-frontends
    window.dispatchEvent(new CustomEvent('auth_changed', { 
      detail: { isAuthenticated: true } 
    }));
  }

  getToken() {
    const token = localStorage.getItem(this.tokenKey);
    const expiry = localStorage.getItem(this.expiryKey);

    if (!token || !expiry) {
      return null;
    }

    // Check if token is expired
    if (new Date().getTime() > parseInt(expiry, 10)) {
      this.clearToken();
      return null;
    }

    return token;
  }

  clearToken() {
    localStorage.removeItem(this.tokenKey);
    localStorage.removeItem(this.expiryKey);

    // Notify other micro-frontends
    window.dispatchEvent(new CustomEvent('auth_changed', { 
      detail: { isAuthenticated: false } 
    }));
  }

  isAuthenticated() {
    return this.getToken() !== null;
  }
}

export default new AuthService();
Enter fullscreen mode Exit fullscreen mode

Each micro-frontend can subscribe to auth changes:

// In any micro-frontend component
import { useEffect, useState } from 'react';
import authService from './authService';

function AuthAwareComponent() {
  const [isAuthenticated, setIsAuthenticated] = useState(authService.isAuthenticated());

  useEffect(() => {
    const handleAuthChange = (e) => {
      setIsAuthenticated(e.detail.isAuthenticated);
    };

    window.addEventListener('auth_changed', handleAuthChange);
    return () => window.removeEventListener('auth_changed', handleAuthChange);
  }, []);

  return (
    <div>
      {isAuthenticated ? <PrivateContent /> : <PublicContent />}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Style Isolation

Style conflicts can derail even the best micro-frontend implementations. I've successfully used several approaches to prevent this issue:

CSS Modules

// In a micro-frontend component
import styles from './ProductList.module.css';

function ProductList() {
  return (
    <div className={styles.container}>
      <h2 className={styles.title}>Products</h2>
      {/* More content */}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Shadow DOM

class IsolatedComponent extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
  }

  connectedCallback() {
    this.shadowRoot.innerHTML = `
      <style>
        .container { padding: 20px; }
        .title { color: blue; }
      </style>
      <div class="container">
        <h2 class="title">Isolated Component</h2>
        <slot></slot>
      </div>
    `;
  }
}

customElements.define('isolated-component', IsolatedComponent);
Enter fullscreen mode Exit fullscreen mode

CSS-in-JS with Scoped Styles

import styled from 'styled-components';

const Container = styled.div`
  padding: 20px;
  background-color: #f5f5f5;
`;

const Title = styled.h2`
  color: #333;
  font-size: 18px;
`;

function ProductList() {
  return (
    <Container>
      <Title>Products</Title>
      {/* More content */}
    </Container>
  );
}
Enter fullscreen mode Exit fullscreen mode

Versioning Strategy

Maintaining compatibility between micro-frontends requires a clear versioning strategy. I implement semantic versioning for all shared interfaces:

// api-client.js (shared package)
// Version 1.0.0
export function fetchProducts() {
  return fetch('/api/products').then(res => res.json());
}

// Version 1.1.0 - Backwards compatible
export function fetchProducts(options = {}) {
  const url = new URL('/api/products', window.location.origin);

  if (options.category) {
    url.searchParams.append('category', options.category);
  }

  return fetch(url).then(res => res.json());
}

// Version 2.0.0 - Breaking change
export function fetchProducts(options = {}) {
  // New implementation that returns a different structure
  return fetch('/api/v2/products', {
    method: 'POST',
    body: JSON.stringify(options)
  }).then(res => res.json());
}
Enter fullscreen mode Exit fullscreen mode

With proper versioning, I can control the upgrade path for dependent micro-frontends:

// package.json for a micro-frontend
{
  "dependencies": {
    "api-client": "^1.0.0" // Accepts 1.0.0, 1.1.0, but not 2.0.0
  }
}
Enter fullscreen mode Exit fullscreen mode

Distributed State Management

Managing state across micro-frontends requires careful design. I've had success with a combination of local state and synchronized global state:

// stateService.js
class StateService {
  constructor(namespace) {
    this.namespace = namespace;
    this.state = {};
    this.listeners = new Set();
  }

  getState() {
    return { ...this.state };
  }

  setState(updates) {
    this.state = { ...this.state, ...updates };
    this.notifyListeners();

    // Synchronize with other micro-frontends
    window.dispatchEvent(new CustomEvent(`${this.namespace}_state_changed`, {
      detail: { state: this.state }
    }));
  }

  subscribe(listener) {
    this.listeners.add(listener);
    return () => this.listeners.delete(listener);
  }

  notifyListeners() {
    this.listeners.forEach(listener => listener(this.state));
  }

  // Initialize and sync with other instances
  init() {
    window.addEventListener(`${this.namespace}_state_changed`, (event) => {
      if (event.detail && event.detail.state) {
        this.state = event.detail.state;
        this.notifyListeners();
      }
    });

    // Request current state from other instances
    window.dispatchEvent(new CustomEvent(`${this.namespace}_state_request`));
  }
}

// Create instances for different domains
export const cartState = new StateService('cart');
export const userState = new StateService('user');

// Initialize
cartState.init();
userState.init();
Enter fullscreen mode Exit fullscreen mode

Using this approach in components:

import React, { useState, useEffect } from 'react';
import { cartState } from './stateService';

function CartIndicator() {
  const [items, setItems] = useState(cartState.getState().items || []);

  useEffect(() => {
    return cartState.subscribe((state) => {
      setItems(state.items || []);
    });
  }, []);

  return <div>Cart Items: {items.length}</div>;
}
Enter fullscreen mode Exit fullscreen mode

Performance Monitoring

Tracking performance across micro-frontends requires a coordinated approach. I've implemented custom monitoring that consolidates metrics:

// performanceMonitor.js
class PerformanceMonitor {
  constructor() {
    this.metrics = {};
    this.observers = new Set();
  }

  recordMetric(name, value, metadata = {}) {
    const timestamp = Date.now();
    const metricData = {
      value,
      timestamp,
      metadata: {
        ...metadata,
        microFrontend: metadata.microFrontend || 'unknown'
      }
    };

    this.metrics[name] = this.metrics[name] || [];
    this.metrics[name].push(metricData);

    // Notify observers
    this.notifyObservers({
      type: 'metric',
      name,
      data: metricData
    });

    // Optionally send to analytics server
    this.sendToAnalytics(name, metricData);
  }

  measurePageLoad(componentName, microFrontend) {
    const startTime = performance.now();

    return {
      end: () => {
        const duration = performance.now() - startTime;
        this.recordMetric('componentLoad', duration, {
          component: componentName,
          microFrontend
        });
        return duration;
      }
    };
  }

  observe(callback) {
    this.observers.add(callback);
    return () => this.observers.delete(callback);
  }

  notifyObservers(data) {
    this.observers.forEach(observer => observer(data));
  }

  sendToAnalytics(name, data) {
    // Implementation to send data to analytics service
    fetch('/api/analytics', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ name, data }),
      keepalive: true // Ensures the request completes even if page is unloaded
    }).catch(err => console.error('Failed to send metrics:', err));
  }
}

export const performanceMonitor = new PerformanceMonitor();
Enter fullscreen mode Exit fullscreen mode

Using it in components:

import React, { useEffect } from 'react';
import { performanceMonitor } from './performanceMonitor';

function ProductList() {
  useEffect(() => {
    const measurement = performanceMonitor.measurePageLoad('ProductList', 'product-app');

    // Simulate loading products
    fetchProducts().then(() => {
      measurement.end();
    });
  }, []);

  // Component implementation
}
Enter fullscreen mode Exit fullscreen mode

To visualize the metrics, I create a dashboard component:

import React, { useState, useEffect } from 'react';
import { performanceMonitor } from './performanceMonitor';
import { LineChart, Line, XAxis, YAxis, Tooltip } from 'recharts';

function PerformanceDashboard() {
  const [metrics, setMetrics] = useState({});

  useEffect(() => {
    setMetrics(performanceMonitor.metrics);

    return performanceMonitor.observe(() => {
      setMetrics({...performanceMonitor.metrics});
    });
  }, []);

  const componentLoadData = (metrics.componentLoad || []).map(item => ({
    name: item.metadata.component,
    value: item.value,
    time: new Date(item.timestamp).toLocaleTimeString()
  }));

  return (
    <div>
      <h2>Performance Metrics</h2>

      <h3>Component Load Times (ms)</h3>
      <LineChart width={600} height={300} data={componentLoadData}>
        <XAxis dataKey="time" />
        <YAxis />
        <Tooltip />
        <Line type="monotone" dataKey="value" stroke="#8884d8" />
      </LineChart>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

The micro-frontend architecture offers significant advantages for large-scale applications, particularly in organizations with multiple teams. By implementing these techniques, I've been able to create systems that balance team autonomy with cohesive user experiences.

Module Federation provides the technical foundation, while careful communication patterns, style isolation, and state management strategies ensure the individual pieces work together seamlessly. Performance monitoring across boundaries helps identify bottlenecks and optimization opportunities.

When properly implemented, users experience a single, cohesive application, while development teams enjoy greater autonomy and productivity. The result is a scalable architecture that can evolve with changing business needs and technological advances.


101 Books

101 Books is an AI-driven publishing company co-founded by author Aarav Joshi. By leveraging advanced AI technology, we keep our publishing costs incredibly low—some books are priced as low as $4—making quality knowledge accessible to everyone.

Check out our book Golang Clean Code available on Amazon.

Stay tuned for updates and exciting news. When shopping for books, search for Aarav Joshi to find more of our titles. Use the provided link to enjoy special discounts!

Our Creations

Be sure to check out our creations:

Investor Central | Investor Central Spanish | Investor Central German | Smart Living | Epochs & Echoes | Puzzling Mysteries | Hindutva | Elite Dev | JS Schools


We are on Medium

Tech Koala Insights | Epochs & Echoes World | Investor Central Medium | Puzzling Mysteries Medium | Science & Epochs Medium | Modern Hindutva

Top comments (0)