Microfrontends: The Architecture Giant Companies Use to Scale Complex Applications
Hello HaWkers, have you ever tried working on a frontend project with 50+ developers where every change caused endless merge conflicts?
Spotify has over 200 squads working simultaneously on the same product. IKEA manages dozens of different applications that need to look like one. Amazon coordinates thousands of developers building independent features. How do these companies manage to scale without creating complete chaos? The answer is microfrontends.
The Problem Microfrontends Solve
Imagine a traditional e-commerce application built as a frontend monolith: product catalog, cart, checkout, user area, admin dashboard - all in a single giant React repository.
Now imagine 100 developers working on this code. Each deploy needs to pass through thousands of tests. A change in checkout can break the catalog. Different teams need to coordinate deploys. The final bundle is 5MB. Build takes 20 minutes.
This is frontend monolith hell, and it's exactly what many companies face when they grow.
Microfrontends apply microservices principles to the frontend: breaking the application into smaller, independent pieces that can be developed, tested, and deployed separately by autonomous teams.
Understanding Microfrontend Architecture
At its core, microfrontends means breaking your application into multiple independent sub-applications, each possibly using different technologies.
Basic Architecture:
// container-app/src/App.jsx - Container application
import React, { lazy, Suspense } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
// Dynamic microfrontend imports
const ProductCatalog = lazy(() => import('catalog/ProductCatalog'));
const ShoppingCart = lazy(() => import('cart/ShoppingCart'));
const Checkout = lazy(() => import('checkout/Checkout'));
const UserDashboard = lazy(() => import('user/Dashboard'));
function App() {
return (
<BrowserRouter>
<div className="app">
{/* Shared header */}
<Header />
{/* Each route loads a different microfrontend */}
<Suspense fallback={<Loading />}>
<Routes>
<Route path="/" element={<ProductCatalog />} />
<Route path="/cart" element={<ShoppingCart />} />
<Route path="/checkout" element={<Checkout />} />
<Route path="/user/*" element={<UserDashboard />} />
</Routes>
</Suspense>
{/* Shared footer */}
<Footer />
</div>
</BrowserRouter>
);
}
export default App;
Each microfrontend (catalog
, cart
, checkout
, user
) is a separate application with its own repository, build, and deploy.
Module Federation: The Game Changer
Webpack 5 introduced Module Federation, which revolutionized microfrontends by allowing code sharing at runtime without needing to republish everything.
Host Configuration (Container):
// container-app/webpack.config.js
const ModuleFederationPlugin = require('webpack/lib/container/ModuleFederationPlugin');
module.exports = {
plugins: [
new ModuleFederationPlugin({
name: 'container',
remotes: {
// URLs of remote microfrontends
catalog: 'catalog@http://localhost:3001/remoteEntry.js',
cart: 'cart@http://localhost:3002/remoteEntry.js',
checkout: 'checkout@http://localhost:3003/remoteEntry.js',
user: 'user@http://localhost:3004/remoteEntry.js'
},
shared: {
// Shared dependencies
react: {
singleton: true,
requiredVersion: '^18.2.0',
eager: true
},
'react-dom': {
singleton: true,
requiredVersion: '^18.2.0',
eager: true
},
'react-router-dom': {
singleton: true,
requiredVersion: '^6.8.0'
}
}
})
]
};
Remote Configuration (Microfrontend):
// catalog-app/webpack.config.js
const ModuleFederationPlugin = require('webpack/lib/container/ModuleFederationPlugin');
module.exports = {
plugins: [
new ModuleFederationPlugin({
name: 'catalog',
filename: 'remoteEntry.js',
exposes: {
// Components exposed to other apps
'./ProductCatalog': './src/ProductCatalog',
'./ProductCard': './src/components/ProductCard',
'./useProducts': './src/hooks/useProducts'
},
shared: {
react: { singleton: true, requiredVersion: '^18.2.0' },
'react-dom': { singleton: true, requiredVersion: '^18.2.0' }
}
})
]
};
With this, the container can load the catalog dynamically at runtime. If you deploy a new version of the catalog, users will see the change instantly without needing to update the container.
Communication Between Microfrontends
One of the biggest challenges is making independent microfrontends communicate. There are several approaches.
Event Bus Pattern:
// shared/eventBus.js - Shared event system
class EventBus {
constructor() {
this.events = {};
}
subscribe(eventName, callback) {
if (!this.events[eventName]) {
this.events[eventName] = [];
}
this.events[eventName].push(callback);
// Returns unsubscribe function
return () => {
this.events[eventName] = this.events[eventName].filter(
cb => cb !== callback
);
};
}
publish(eventName, data) {
if (!this.events[eventName]) return;
this.events[eventName].forEach(callback => {
try {
callback(data);
} catch (error) {
console.error(`Error in event handler for ${eventName}:`, error);
}
});
}
clear(eventName) {
if (eventName) {
delete this.events[eventName];
} else {
this.events = {};
}
}
}
// Global singleton
window.__SHARED_EVENT_BUS__ = window.__SHARED_EVENT_BUS__ || new EventBus();
export default window.__SHARED_EVENT_BUS__;
Usage in Cart Microfrontend:
// cart-app/src/Cart.jsx
import React, { useState, useEffect } from 'react';
import eventBus from 'shared/eventBus';
function ShoppingCart() {
const [items, setItems] = useState([]);
useEffect(() => {
// Listen for "add to cart" events
const unsubscribe = eventBus.subscribe('cart:addItem', (product) => {
setItems(prev => {
const existing = prev.find(item => item.id === product.id);
if (existing) {
// Increment quantity if already exists
return prev.map(item =>
item.id === product.id
? { ...item, quantity: item.quantity + 1 }
: item
);
}
// Add new item
return [...prev, { ...product, quantity: 1 }];
});
// Notify other microfrontends
eventBus.publish('cart:updated', items.length + 1);
});
// Cleanup
return unsubscribe;
}, [items]);
const removeItem = (itemId) => {
const newItems = items.filter(item => item.id !== itemId);
setItems(newItems);
eventBus.publish('cart:updated', newItems.length);
};
const updateQuantity = (itemId, quantity) => {
if (quantity <= 0) {
removeItem(itemId);
return;
}
setItems(prev =>
prev.map(item =>
item.id === itemId ? { ...item, quantity } : item
)
);
};
const total = items.reduce((sum, item) => {
return sum + (item.price * item.quantity);
}, 0);
return (
<div className="shopping-cart">
<h2>Cart ({items.length})</h2>
{items.length === 0 ? (
<p>Empty cart</p>
) : (
<>
{items.map(item => (
<div key={item.id} className="cart-item">
<img src={item.image} alt={item.name} />
<div>
<h3>{item.name}</h3>
<p>${item.price.toFixed(2)}</p>
</div>
<div>
<button onClick={() => updateQuantity(item.id, item.quantity - 1)}>
-
</button>
<span>{item.quantity}</span>
<button onClick={() => updateQuantity(item.id, item.quantity + 1)}>
+
</button>
</div>
<button onClick={() => removeItem(item.id)}>Remove</button>
</div>
))}
<div className="cart-total">
<h3>Total: ${total.toFixed(2)}</h3>
<button onClick={() => eventBus.publish('checkout:start', items)}>
Checkout
</button>
</div>
</>
)}
</div>
);
}
export default ShoppingCart;
Usage in Catalog Microfrontend:
// catalog-app/src/ProductCard.jsx
import React from 'react';
import eventBus from 'shared/eventBus';
function ProductCard({ product }) {
const addToCart = () => {
// Publish event that cart will listen to
eventBus.publish('cart:addItem', product);
// Visual feedback
showNotification(`${product.name} added to cart!`);
};
return (
<div className="product-card">
<img src={product.image} alt={product.name} />
<h3>{product.name}</h3>
<p className="description">{product.description}</p>
<p className="price">${product.price.toFixed(2)}</p>
<button onClick={addToCart}>Add to Cart</button>
</div>
);
}
This pattern allows microfrontends to communicate without knowing each other's implementation details.
Shared State Between Microfrontends
For global state that needs to be shared, you can use several strategies.
Shared Store with Zustand:
// shared/stores/userStore.js
import create from 'zustand';
// Store shared among all microfrontends
const useUserStore = create((set, get) => ({
user: null,
isAuthenticated: false,
login: async (credentials) => {
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(credentials)
});
const user = await response.json();
set({
user,
isAuthenticated: true
});
// Notify other microfrontends
window.dispatchEvent(new CustomEvent('user:login', { detail: user }));
return user;
} catch (error) {
console.error('Login failed:', error);
throw error;
}
},
logout: () => {
set({
user: null,
isAuthenticated: false
});
window.dispatchEvent(new CustomEvent('user:logout'));
},
updateProfile: (updates) => {
const user = get().user;
if (!user) return;
const updatedUser = { ...user, ...updates };
set({ user: updatedUser });
window.dispatchEvent(
new CustomEvent('user:updated', { detail: updatedUser })
);
}
}));
// Expose globally to all microfrontends
window.__USER_STORE__ = useUserStore;
export default useUserStore;
Each microfrontend can import and use this store, maintaining automatic synchronization.
Challenges and Solutions
Microfrontends aren't a silver bullet. They come with their own challenges.
Performance and Bundle Size
Each microfrontend adds overhead. If you're not careful, you can end up with multiple copies of React being downloaded.
Solution: Use shared
in Module Federation aggressively and implement intelligent code splitting.
Complex Debugging
Debugging issues that cross multiple microfrontends is harder.
Solution: Invest in structured logging, distributed tracing, and tools like Sentry with microfrontend context.
Versioning
Ensuring compatibility between versions of different microfrontends is challenging.
Solution: Define clear API contracts, use semantic versioning rigorously, and implement integration tests between microfrontends.
UI Consistency
Maintaining consistent design system between autonomous teams requires discipline.
Solution: Shared design system as versioned npm library, with CI/CD to ensure updates.
When to Use (and When Not to Use) Microfrontends
Microfrontends are powerful but add significant complexity.
Use Microfrontends when:
- You have multiple autonomous teams (10+ developers)
- Application is large and naturally divisible
- Need to scale teams independently
- Different parts have distinct release cycles
- Want to experiment with different technologies in parts of the application
Don't use when:
- Team is small (less than 10 devs)
- Application is relatively simple
- Everyone works in the same context
- Don't have infrastructure to support multiple deploys
For most projects, a well-structured monolith is simpler and sufficient. Microfrontends are for when you already have the problems they solve.
The Future of Microfrontends
Tools are making microfrontends increasingly accessible. Single-SPA, Nx, Turborepo, and frameworks like Qwik with native lazy loading are pushing the limits.
Module Federation 2.0 promises massive improvements in performance and developer experience. And platforms like Vercel and Netlify are starting to offer optimized deployment for microfrontends.
If you're building complex and scalable applications, I also recommend exploring Serverless with JavaScript, another architecture that combines perfectly with microfrontends to create truly elastic systems.
Let's go! π¦
π Want to Deepen Your JavaScript Knowledge?
This article covered microfrontends and scalable architecture, but there's much more to explore in modern development.
Developers who invest in solid, structured knowledge tend to have more opportunities in the market.
Complete Study Material
If you want to master JavaScript from basics to advanced, I've prepared a complete guide:
Investment options:
- 2x of $13.08 on card
- or $24.90 at sight
π Learn About JavaScript Guide
π‘ Material updated with industry best practices