Micro Frontend Architecture Fundamentals: From Monolith to Distributed Systems
Micro frontend composition strategies compared: server-side, build-time, Module Federation, and iframe, with the team preconditions that make the trade pay off.
Micro frontend architectures split a single-page frontend into independently deployable, independently owned slices that compose at runtime. They solve a specific set of problems (team-size scaling, independent release cadence, technology flexibility) and introduce a corresponding set of new ones (runtime composition complexity, shared-dependency management, cross-slice state, cross-slice performance). The choice to adopt them is rarely a clean win; it is a trade of coordination overhead for release autonomy, and the trade only pays back at certain team sizes and product-structure boundaries.
The safe default is the simplest composition your team boundaries allow: build-time integration inside a monorepo. Runtime composition waits until independent release cadence becomes the binding constraint. The sections below start from that default. They cover the four composition strategies (server-side, build-time, runtime via Module Federation, iframe), the shared-dependency patterns each one forces, and the preconditions that make the trade worth paying for.
Micro Frontend Series Navigation
- Part 1 (You are here): Architecture fundamentals and implementation types
- Part 2: Module Federation, communication patterns, and integration strategies
- Part 3: Advanced patterns, performance optimization, and production debugging
What Are Micro Frontends?
Micro frontends extend the microservices concept to frontend development. Instead of a single monolithic frontend application, you compose multiple smaller, independently deployable frontend applications into a cohesive user experience.
The key principles are:
- Technology Agnostic: Teams can choose their own frameworks and tools
- Independent Deployment: Each micro frontend can be deployed independently
- Team Autonomy: Different teams can own different parts of the application
- Incremental Migration: Gradual migration from monoliths is possible
Types of Micro Frontend Architectures
Here are the four main architectural patterns, each with distinct characteristics and use cases:
1. Server-Side Template Composition
The simplest approach where different services render HTML fragments that are composed on the server.
// Gateway service composing multiple micro frontends
import express from 'express';
import fetch from 'node-fetch';
const app = express();
app.get('/', async (req, res) => {
try {
// Fetch fragments from different services
const [header, navigation, content, footer] = await Promise.all([
fetch('http://header-service/fragment').then(r => r.text()),
fetch('http://nav-service/fragment').then(r => r.text()),
fetch('http://content-service/fragment').then(r => r.text()),
fetch('http://footer-service/fragment').then(r => r.text())
]);
const html = `
<!DOCTYPE html>
<html>
<head>
<title>Composed Application</title>
</head>
<body>
${header}
${navigation}
<main>${content}</main>
${footer}
</body>
</html>
`;
res.send(html);
} catch (error) {
res.status(500).send('Error composing page');
}
});
Pros: Simple to understand, good SEO, works without JavaScript Cons: Limited interactivity, page refreshes for navigation, shared state challenges
When to use: Content-heavy sites, when SEO is critical, teams comfortable with server-side development
2. Build-Time Integration
Micro frontends are published as npm packages and composed at build time. The shell pins each one as a versioned dependency:
{
"dependencies": {
"@company/header-mf": "^1.2.0",
"@company/product-catalog-mf": "^2.1.5",
"@company/checkout-mf": "^1.8.2"
}
}
The shell then imports them like any other package:
// Shell application
import React from 'react';
import { Header } from '@company/header-mf';
import { ProductCatalog } from '@company/product-catalog-mf';
import { Checkout } from '@company/checkout-mf';
const App: React.FC = () => {
return (
<div>
<Header />
<main>
<ProductCatalog />
<Checkout />
</main>
</div>
);
};
export default App;
// Micro frontend package (header-mf)
import React from 'react';
export interface HeaderProps {
user?: {
name: string;
avatar: string;
};
onLogout?: () => void;
}
export const Header: React.FC<HeaderProps> = ({ user, onLogout }) => {
return (
<header className="bg-blue-600 text-white p-4">
<div className="flex justify-between items-center">
<h1>My App</h1>
{user && (
<div className="flex items-center gap-2">
<img src={user.avatar} alt={user.name} className="w-8 h-8 rounded-full" />
<span>{user.name}</span>
<button onClick={onLogout}>Logout</button>
</div>
)}
</div>
</header>
);
};
Pros: Type safety, shared dependencies optimization, familiar development experience Cons: Coordinated deployments, version management complexity, not truly independent
When to use: When you want micro frontend benefits but can tolerate coordinated deployments
3. Runtime Integration via JavaScript
The most flexible approach where micro frontends are loaded and integrated at runtime.
// Micro frontend registry
declare const __webpack_init_sharing__: (scope: string) => Promise<void>;
declare const __webpack_share_scopes__: Record<string, any>;
interface MicroFrontendConfig {
name: string;
url: string;
scope: string;
module: string;
}
class MicroFrontendRegistry {
private configs: Map<string, MicroFrontendConfig> = new Map();
private loadedModules: Map<string, any> = new Map();
register(config: MicroFrontendConfig) {
this.configs.set(config.name, config);
}
async load(name: string): Promise<any> {
if (this.loadedModules.has(name)) {
return this.loadedModules.get(name);
}
const config = this.configs.get(name);
if (!config) {
throw new Error(`Micro frontend ${name} not registered`);
}
// Dynamic import with error handling
try {
await this.loadScript(config.url);
await __webpack_init_sharing__('default');
const container = (window as any)[config.scope];
if (!container) {
throw new Error(`Container ${config.scope} not found`);
}
// The remote joins the host share scope, so react stays a singleton
await container.init(__webpack_share_scopes__.default);
const factory = await container.get(config.module);
const Module = factory();
this.loadedModules.set(name, Module);
return Module;
} catch (error) {
console.error(`Failed to load micro frontend ${name}:`, error);
throw error;
}
}
private loadScript(url: string): Promise<void> {
return new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = url;
script.onload = () => resolve();
script.onerror = () => reject(new Error(`Failed to load script: ${url}`));
document.head.appendChild(script);
});
}
}
// Usage in shell application
const registry = new MicroFrontendRegistry();
registry.register({
name: 'product-catalog',
url: 'http://localhost:3001/remoteEntry.js',
scope: 'productCatalog',
module: './ProductCatalog'
});
const DynamicMicroFrontend: React.FC<{ name: string }> = ({ name }) => {
const [Component, setComponent] = useState<React.ComponentType | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
registry.load(name)
.then(Module => {
setComponent(() => Module.default || Module);
setError(null);
})
.catch(err => {
setError(err.message);
setComponent(null);
})
.finally(() => setLoading(false));
}, [name]);
if (loading) return <div>Loading {name}...</div>;
if (error) return <div>Error loading {name}: {error}</div>;
if (!Component) return <div>Component {name} not found</div>;
return <Component />;
};
Pros: True independence, different technology stacks possible, runtime flexibility Cons: Complexity, runtime errors, performance overhead, debugging challenges
When to use: Large organizations with multiple teams, need for technology diversity
4. Iframe-Based Integration
The most isolated approach using iframes for complete separation.
// Iframe micro frontend wrapper with postMessage communication
interface IframeMicroFrontendProps {
src: string;
name: string;
onMessage?: (data: any) => void;
}
const IframeMicroFrontend: React.FC<IframeMicroFrontendProps> = ({
src,
name,
onMessage
}) => {
const iframeRef = useRef<HTMLIFrameElement>(null);
const [isLoaded, setIsLoaded] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const handleMessage = (event: MessageEvent) => {
// Verify origin for security
if (event.origin !== new URL(src).origin) {
return;
}
if (event.data.source === name) {
onMessage?.(event.data.payload);
}
};
window.addEventListener('message', handleMessage);
return () => window.removeEventListener('message', handleMessage);
}, [src, name, onMessage]);
const sendMessage = (data: any) => {
if (iframeRef.current?.contentWindow) {
iframeRef.current.contentWindow.postMessage({
source: 'shell',
target: name,
payload: data
}, new URL(src).origin);
}
};
return (
<div className="micro-frontend-container">
{!isLoaded && <div>Loading {name}...</div>}
{error && <div>Error: {error}</div>}
<iframe
ref={iframeRef}
src={src}
onLoad={() => setIsLoaded(true)}
onError={() => setError(`Failed to load ${name}`)}
style={{
width: '100%',
border: 'none',
minHeight: '400px'
}}
title={name}
sandbox="allow-scripts allow-same-origin allow-forms"
/>
</div>
);
};
// Inside the micro frontend (iframe content)
const SHELL_ORIGIN = 'https://shell.example.com';
const MicroFrontendApp: React.FC = () => {
const [data, setData] = useState<any>(null);
useEffect(() => {
const handleMessage = (event: MessageEvent) => {
if (event.origin !== SHELL_ORIGIN) {
return;
}
if (event.data.target === 'product-catalog') {
setData(event.data.payload);
}
};
window.addEventListener('message', handleMessage);
return () => window.removeEventListener('message', handleMessage);
}, []);
const sendDataToShell = (payload: any) => {
// Name the shell origin instead of posting to '*'
window.parent.postMessage({
source: 'product-catalog',
payload
}, SHELL_ORIGIN);
};
return (
<div>
<h2>Product Catalog Micro Frontend</h2>
{/* Your micro frontend content */}
</div>
);
};
Pros: Complete isolation, security, different domains possible, CSS isolation Cons: Limited communication, SEO challenges, performance overhead, UX considerations
When to use: Security is paramount, legacy integration, third-party content
Debugging Case Study: The Vanishing Styles Problem
A common micro frontend pitfall surfaces when a runtime integration system works perfectly in development but produces missing styles in production. Consider a product catalog micro frontend where symptoms look like this:
- Styles work fine when the micro frontend runs standalone
- The issue only occurs in production, not development
- It is intermittent: sometimes styles load, sometimes they do not
Investigation points to the root cause: CSS loading race conditions.
// The problematic code
const ProductCatalogMF: React.FC = () => {
useEffect(() => {
// This was loading CSS after component mount
import('./styles.css');
}, []);
return <div className="product-grid">...</div>;
};
In production, with more aggressive minification and CDN caching, the CSS import completes after the component has already rendered. The solution requires a more robust loading strategy:
// Fixed version with proper CSS loading
const MicroFrontendLoader = {
async loadWithStyles(name: string, cssUrls: string[] = []) {
// Load CSS first
await Promise.all(
cssUrls.map(url => this.loadStylesheet(url))
);
// Then load the component
return await registry.load(name);
},
loadStylesheet(url: string): Promise<void> {
return new Promise((resolve, reject) => {
// Check if already loaded
if (document.querySelector(`link[href="${url}"]`)) {
resolve();
return;
}
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = url;
link.onload = () => resolve();
link.onerror = () => reject(new Error(`Failed to load CSS: ${url}`));
document.head.appendChild(link);
});
}
};
This failure pattern underscores three implementation requirements:
- Proper loading sequences in micro frontend architectures
- Environment parity: production issues often do not manifest in development
- Monitoring and observability: CSS load tracking catches these issues early
Performance Considerations
Micro frontends introduce unique performance challenges:
Bundle Size and Duplication
Multiple micro frontends often ship the same dependencies, leading to bloated bundles.
// Webpack configuration for shared dependencies
// Module Federation 2.0 ships as @module-federation/enhanced, not the webpack built-in
const { ModuleFederationPlugin } = require('@module-federation/enhanced/webpack');
module.exports = {
plugins: [
new ModuleFederationPlugin({
name: 'shell',
remotes: {
productCatalog: 'productCatalog@http://localhost:3001/remoteEntry.js',
},
shared: {
react: {
singleton: true,
requiredVersion: '^18.0.0',
},
'react-dom': {
singleton: true,
requiredVersion: '^18.0.0',
},
// Share common utilities
lodash: {
singleton: false, // Allow multiple versions if needed
}
},
}),
],
};
Loading Performance
Implement progressive loading strategies:
// Progressive micro frontend loading
const ProgressiveMicroFrontend: React.FC<{
name: string;
priority: 'high' | 'medium' | 'low';
}> = ({ name, priority }) => {
const [shouldLoad, setShouldLoad] = useState(priority === 'high');
const isVisible = useIntersectionObserver();
useEffect(() => {
if (priority === 'medium' && isVisible) {
setShouldLoad(true);
} else if (priority === 'low') {
// Load after main content is ready
const timer = setTimeout(() => setShouldLoad(true), 2000);
return () => clearTimeout(timer);
}
}, [isVisible, priority]);
if (!shouldLoad) {
return <div>Loading {name}...</div>;
}
return <DynamicMicroFrontend name={name} />;
};
Choosing the Right Architecture
The choice depends on your specific constraints:
| Factor | Server-Side | Build-Time | Runtime | Iframe |
|---|---|---|---|---|
| Team Independence | Low | Medium | High | High |
| Technology Diversity | Medium | Low | High | High |
| Performance | High | High | Medium | Low |
| Complexity | Low | Medium | High | Medium |
| SEO | Excellent | Good | Poor | Poor |
| Development Experience | Good | Excellent | Medium | Poor |
Next in the Series
The four patterns above are the decision surface. The cost of the choice lands in the integration layer.
Continue to Part 2: Module Federation and Implementation Patterns for:
- Production-ready Module Federation configurations
- Robust error handling and fallback strategies
- Cross-micro frontend communication patterns
- Routing coordination between applications
- Development workflows and tooling
- Debugging walkthroughs for common runtime failures
Build-time integration inside a monorepo stays the right default while one release train can carry the whole frontend. Override it when separate teams need to ship on separate days, when a legacy stack has to live beside a new one, or when third-party content must stay sandboxed. At that point pick the composition model from the table above rather than reaching for runtime loading by reflex.
Series Navigation
- Part 1 (Current): Architecture fundamentals
- Part 2: Implementation patterns
- Part 3: Advanced patterns & debugging
References
- Micro Frontends - Martin Fowler - Foundational article on micro frontend architecture covering patterns, benefits, and implementation strategies
- micro-frontends.org - Community resource covering techniques and strategies for building independent-team frontend systems
- Module Federation Concepts - webpack - Official webpack documentation introducing Module Federation for sharing code between independently deployed apps
- single-spa Getting Started - Comprehensive guide to single-spa, a popular micro frontend orchestration framework
- Microfrontends Overview - single-spa - Conceptual overview of micro frontend types and architectural approaches
Micro Frontend Architecture Guide
A 3-part comprehensive guide to micro frontend architecture, from fundamental concepts to advanced patterns and production debugging strategies.
All Posts in This Series
Related posts
Production-ready Module Federation setups, cross-app communication, routing strategies, and the race conditions that break split routing under load.
A practical comparison of TypeScript AI SDKs for building agents: Vercel AI SDK, OpenAI Agents SDK, and AWS Bedrock, with code examples and decision frameworks.
A comparison of modern TypeScript linting and formatting tools - ESLint, Prettier, Biome, and Oxlint - with benchmarks, config examples, and migration tips.
How SOLID principles apply to modern JavaScript: practical examples with TypeScript, React hooks, and functional patterns, plus when they're overkill.
A practical guide to learning Effect incrementally and integrating it with AWS Lambda, with real code examples, common pitfalls, and production patterns.