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']
})
]
};
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>
);
}
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;
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
}
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);
}
};
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);
});
}
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();
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>
);
}
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>
);
}
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);
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>
);
}
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());
}
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
}
}
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();
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>;
}
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();
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
}
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>
);
}
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)