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.
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.
Teams pick their own frameworks and tools, deploy on their own schedule, and own a slice of the product end to end. A monolith can be migrated one slice at a time.
Four Ways to Compose a Frontend#
Server-Side Template Composition#
// 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');
}
});
Pages work without JavaScript and search engines see complete HTML. What you give up is interactivity: every navigation costs a page refresh, and shared state has nowhere natural to live. Content-heavy sites with teams already comfortable on the server side get the most out of it.
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>
);
};
Deployments stay coordinated here and someone still manages versions across the shell and its packages, so independent shipping is not part of the deal. In exchange, each slice behaves like an ordinary import: type safety, shared-dependency optimization, and a development experience nobody has to relearn.
Runtime Integration via JavaScript#
// 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, and debugging challenges.
Iframe-Based Integration#
// 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>
);
};
Isolation is the whole point here: a separate origin, a separate CSS scope, a separate security boundary. That isolation is expensive. Communication narrows to postMessage, SEO suffers, and a full document inside a document carries a performance and UX cost.
Styles That Vanish Only in Production#
A runtime integration can work perfectly in development and still ship missing styles. The micro frontend renders fine standalone, and in production the failure is intermittent: sometimes the styles load, sometimes they do not.
The root cause is a CSS loading race condition.
// 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 fix makes the loading order explicit instead of leaving it to import timing:
// 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);
});
}
};
Shared Dependencies and Load Order#
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
}
},
}),
],
};
Priority-Based Loading#
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} />;
};
Picking by Constraint#
| 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#
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
Move off the build-time default when release cadences have to diverge, or when content you cannot vet has to stay sandboxed. A legacy stack living beside a new one is the other case worth the runtime cost.
References#
- Micro Frontends - Martin Fowler (opens in new tab) - Foundational article on micro frontend architecture covering patterns, benefits, and implementation strategies
- micro-frontends.org (opens in new tab) - Community resource covering techniques and strategies for building independent-team frontend systems
- Module Federation Concepts - webpack (opens in new tab) - Official webpack documentation introducing Module Federation for sharing code between independently deployed apps
- single-spa Getting Started (opens in new tab) - Comprehensive guide to single-spa, a popular micro frontend orchestration framework
- Microfrontends Overview - single-spa (opens in new tab) - 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.
build-tools · react · tutorial +2
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.
typescript · ai-tools · serverless +4
A comparison of modern TypeScript linting and formatting tools - ESLint, Prettier, Biome, and Oxlint - with benchmarks, config examples, and migration tips.
typescript · code-quality · developer-experience +2
How SOLID principles apply to modern JavaScript: practical examples with TypeScript, React hooks, and functional patterns, plus when they're overkill.
typescript · javascript · react +4
A practical guide to learning Effect incrementally and integrating it with AWS Lambda, with real code examples, common pitfalls, and production patterns.
typescript · functional-programming · lambda +4