Micro-Frontends in Practice: Module Federation, Independent Deployments, and When the Complexity Is Worth It
A practical guide to micro-frontend architecture for growing engineering teams. Covers real problems, implementation approaches, shared state and routing challenges, TypeScript integration, performance implications, and a clear decision framework.
Most teams reach for micro-frontends because the monolith is becoming a problem. Deployments are serialized. Teams step on each other’s code. Adding a new product area means touching a repository that ten people are actively changing. That friction is real, and it compounds.
But micro-frontends also introduce problems you did not have before: bundle duplication, loading waterfalls, distributed state, routing that spans ownership boundaries, and organizational overhead that slows you down before it speeds you up. Teams often adopt the architecture before they feel the pain it solves, or after they have already felt it and are making a reactive decision under pressure.
This article is for engineers evaluating whether micro-frontends are the right call for their current situation, and for engineers already committed who want to understand the implementation details that matter.
The Problems Micro-Frontends Actually Solve
Before any architecture discussion, it is worth being precise about what you are solving.
Team autonomy without coordination overhead. When two teams own the same codebase, they coordinate on every deploy. That coordination has a cost: PR reviews across team boundaries, shared CI pipelines that become bottlenecks, release trains where one team’s bug holds another team’s feature. Micro-frontends let each team own its slice end-to-end, deploy independently, and move at its own pace.
Incremental migration. If you have a legacy frontend (Angular, Backbone, jQuery-era jQuery spaghetti) and you want to move to React or Vue, a full rewrite is high-risk and slow. Micro-frontends let you wrap the old application and ship new features in the new stack alongside it. Teams at large organizations have used this pattern to migrate applications over two or three years without a flag-day cutover.
Independent deployments. This is related to team autonomy but worth naming separately. Independent deployment means one team’s code can go to production without waiting for another team’s code to be ready. That matters when you are running multiple product lines from a single domain and release cadences diverge.
If your situation does not involve multiple teams with diverging deployment needs, or an incremental migration scenario, the case for micro-frontends weakens significantly.
Implementation Approaches
Webpack Module Federation
Module Federation, introduced in Webpack 5, is the most widely deployed mechanism for runtime composition of separate builds. Each application exposes modules that other applications can load at runtime, sharing dependencies to avoid duplication.
A host application consumes a remote like this:
// webpack.config.ts (host)
import { ModuleFederationPlugin } from "@module-federation/enhanced";
export default {
plugins: [
new ModuleFederationPlugin({
name: "shell",
remotes: {
checkout: "checkout@https://checkout.internal/remoteEntry.js",
catalog: "catalog@https://catalog.internal/remoteEntry.js",
},
shared: {
react: { singleton: true, requiredVersion: "^18.0.0" },
"react-dom": { singleton: true, requiredVersion: "^18.0.0" },
},
}),
],
};
The remote exposes a component:
// webpack.config.ts (remote: checkout)
import { ModuleFederationPlugin } from "@module-federation/enhanced";
export default {
plugins: [
new ModuleFederationPlugin({
name: "checkout",
filename: "remoteEntry.js",
exposes: {
"./CheckoutFlow": "./src/CheckoutFlow",
},
shared: {
react: { singleton: true, requiredVersion: "^18.0.0" },
"react-dom": { singleton: true, requiredVersion: "^18.0.0" },
},
}),
],
};
The host loads it at runtime:
// In the host application
import React, { Suspense, lazy } from "react";
const CheckoutFlow = lazy(() => import("checkout/CheckoutFlow"));
export function CheckoutPage() {
return (
<Suspense fallback={<div>Loading checkout...</div>}>
<CheckoutFlow />
</Suspense>
);
}
The singleton: true flag on React is load-bearing. Without it, each remote can load its own copy of React, and you end up with multiple React instances running simultaneously. That breaks hooks. Always mark shared framework dependencies as singletons with a required version range.
Where Module Federation breaks down: the remoteEntry.js URL is often baked into the host build. Updating a remote’s URL after the host is deployed requires redeploying the host, which negates some of the independent deployment benefit. The @module-federation/enhanced package addresses this with dynamic container loading, but it adds complexity.
Import Maps
Import maps are a browser-native alternative. You define module specifiers and their URLs in a JSON structure injected into the HTML:
<script type="importmap">
{
"imports": {
"react": "https://cdn.example.com/react@18.2.0/index.js",
"checkout": "https://checkout.internal/checkout.js"
}
}
</script>
The advantage: no build-tool coupling. Any ES module can be mapped. The disadvantage: browser support is still not universal enough for all teams, and the tooling ecosystem is less mature than Module Federation. For teams on a modern browser target with Vite-based builds, import maps combined with <link rel="modulepreload"> can work well.
Server-Side Composition
Instead of composing in the browser, the server assembles the final HTML from fragments owned by different services. Nginx with sub-requests, Edge Side Includes (ESI), or server-rendered partials stitched together by an edge worker are all variants of this pattern.
// Edge worker composition example (Cloudflare Workers)
export default {
async fetch(request: Request): Promise<Response> {
const [shellHtml, catalogFragment] = await Promise.all([
fetch("https://shell.internal/"),
fetch("https://catalog.internal/fragment/featured"),
]);
const shell = await shellHtml.text();
const catalog = await catalogFragment.text();
const assembled = shell.replace(
'<div id="catalog-slot"></div>',
catalog
);
return new Response(assembled, {
headers: { "content-type": "text/html" },
});
},
};
Server-side composition has better initial load performance because the browser receives complete HTML. It avoids client-side waterfall loading. The tradeoff: composition latency moves to the server (or edge), cross-fragment interactivity is harder, and fragment owners need to expose server-renderable entry points.
Iframes
Iframes are the oldest isolation mechanism and still valid for specific use cases: embedding third-party widgets, strict security isolation between applications with different trust levels, or embedding legacy applications that cannot be refactored. For first-party micro-frontends, iframes introduce too much overhead: navigation isolation, postMessage-based communication, and layout constraints make them unsuitable as a general composition primitive.
Shared State and Routing
This is where micro-frontend projects underestimate scope. State and routing are not framework concerns. They are ownership concerns. When two teams’ applications share a URL space and need to share user state, you need contracts.
Routing. The shell owns the top-level router. Remotes own sub-routes within their namespace. A common pattern: the shell listens for navigation events from remotes and updates the URL using the History API. Remotes should not directly call window.history.pushState without coordinating with the shell.
// Shell: listens for navigation requests from remotes
window.addEventListener("mfe:navigate", (event: CustomEvent) => {
const { path } = event.detail;
window.history.pushState({}, "", path);
// re-render the shell with the new route
});
// Remote: requests navigation without owning history
function navigateTo(path: string) {
window.dispatchEvent(
new CustomEvent("mfe:navigate", { detail: { path } })
);
}
Shared state. The cleanest approach is to share nothing. Each remote is responsible for fetching its own data. If they share an authenticated user session, the shell passes the user identity as a prop or stores it in a shared context the remote reads at mount time. Avoid a global shared store (Redux, Zustand) across remotes. That coupling defeats team autonomy.
// Shell passes auth context at mount time
interface MfeContext {
userId: string;
authToken: string;
tenantId: string;
}
function mountRemote(
element: HTMLElement,
context: MfeContext,
remoteName: string
) {
const container = document.createElement("div");
element.appendChild(container);
// Remote reads context, does not subscribe to shell state
window[`__mfe_context_${remoteName}`] = context;
import(/* @vite-ignore */ `${remoteName}/bootstrap`).then((mod) => {
mod.mount(container, context);
});
}
If remotes genuinely need to communicate (one remote triggers an action that another remote reacts to), use a lightweight event bus scoped to the window. Avoid direct function calls across ownership boundaries.
TypeScript Integration
TypeScript’s module resolution is build-time. Module Federation is runtime. That tension requires bridging.
The standard approach: each remote generates type declarations and publishes them to a shared package. The @module-federation/enhanced TypeScript plugin automates this.
For teams without that tooling, a manual contract works:
// packages/checkout-types/src/index.ts
// Published by the checkout team, consumed by any host
export interface CheckoutFlowProps {
cartId: string;
onSuccess: (orderId: string) => void;
onCancel: () => void;
}
export interface CheckoutMfe {
mount: (element: HTMLElement, props: CheckoutFlowProps) => () => void;
// returns an unmount function
}
The host imports from the types package:
import type { CheckoutMfe } from "@acme/checkout-types";
async function loadCheckout(): Promise<CheckoutMfe> {
const mod = await import("checkout/CheckoutFlow");
return mod as CheckoutMfe;
}
This is not perfect. The types package can drift from the actual implementation. You need a process: the checkout team bumps the types package version when the interface changes, and consuming teams update their dependency. Without that discipline, type safety in this seam erodes quickly.
Performance Implications
Micro-frontends have a real performance cost at the loading layer. Understanding it lets you make it acceptable.
Bundle duplication. Even with shared dependencies configured correctly, multiple remotes ship their own business logic bundles. A monolith with code splitting serves one optimized bundle graph. Five separate micro-frontends serve five separate graphs, each with their own tree-shaking boundaries. Total bytes delivered to the browser tend to increase.
Loading waterfalls. The shell must load and execute before it can discover which remotes are needed. Each remote then loads its own entry file. Sequential: shell load, shell parse and execute, remote discovery, remote entry load, remote parse and execute, render. This can add 500ms to 1500ms of additional latency on first load compared to a server-rendered monolith.
Mitigations that actually help:
<link rel="preload">the remote entry files from the shell’s HTML. The browser can start loading them before JavaScript has even parsed the shell bundle.- Use HTTP/2 or HTTP/3. Parallel loading of remote entries over a single connection removes the per-request connection overhead.
- Place remotes behind a CDN. Remote entry files change infrequently (only on deploy). Long-lived cache headers with cache busting on the URL are appropriate.
- Server-side composition for above-the-fold content. Only use client-side federation for below-the-fold or deferred routes.
Tradeoffs Table
| Dimension | Module Federation | Import Maps | Server-Side Composition |
|---|---|---|---|
| Initial load performance | Waterfall on first visit | Waterfall on first visit | Best (complete HTML from server) |
| Team coupling | Low (deploy independently) | Low (deploy independently) | Low, but fragment APIs are a contract |
| TypeScript integration | Needs extra tooling | Straightforward | Not applicable (HTML fragments) |
| Shared dependency dedup | Configured via shared config | Configured via importmap | Not applicable |
| Browser support | Webpack 5+ builds needed | Modern browsers only | Universal |
| Operational complexity | Medium-High | Medium | High (edge composition infra) |
| Incremental migration | Excellent | Good | Excellent |
| Cross-fragment interactivity | Medium (event bus or shared context) | Medium | Hard (page-level communication only) |
| Local development | Needs remote stubs or full stack | Simpler | Complex (all services must run) |
Production Considerations
Versioning remote contracts. When a remote changes its public interface (component props, event shapes, exported types), it must do so in a backward-compatible way or coordinate a synchronized update with consuming hosts. Semantic versioning the remotes is not enough. You need a deprecation window: expose both old and new interfaces simultaneously, give consuming teams a sprint to migrate, then remove the old.
Health monitoring per remote. A failed remote entry load is a silent blank section in your UI unless you handle it explicitly. Every lazy() import or dynamic import() call should have an error boundary:
class RemoteErrorBoundary extends React.Component<
{ remoteName: string; children: React.ReactNode },
{ hasError: boolean }
> {
state = { hasError: false };
static getDerivedStateFromError() {
return { hasError: true };
}
componentDidCatch(error: Error) {
console.error(`Remote ${this.props.remoteName} failed to load:`, error);
// report to your error tracking service
}
render() {
if (this.state.hasError) {
return <div>This section is temporarily unavailable.</div>;
}
return this.props.children;
}
}
Wrap every remote mount point with an error boundary. A checkout failure should not blank your entire catalog page.
Shared dependency version conflicts. The requiredVersion constraint in Module Federation generates a warning at runtime if a remote ships a version outside the declared range. In production, you want this to be observable. Log the version mismatch to your telemetry system. A React 18.2.0 host loading a React 18.3.0 remote can silently exhibit unexpected behavior.
Local development. The most common complaint from teams using micro-frontends is that local development becomes painful. You need all remotes running locally, or you need stub remotes that approximate production behavior. A practical setup: run your own remote locally, point to staging URLs for all others, and use a local proxy to intercept requests to your remote’s entry file.
Deployment ordering. When multiple remotes and a shell deploy simultaneously, there is a window where the shell is serving cached HTML that references old remote entry URLs, or new remote entry files that the old shell does not know how to handle. Blue-green deployments at the CDN level, with staggered rollouts that let the shell propagate before remotes cut over, reduce this window.
Decision Framework
Start here: how many independent teams will own and deploy pieces of the frontend?
If the answer is one team, do not use micro-frontends. A well-structured monorepo with clear module boundaries and good code splitting gives you almost all the architectural benefits with none of the operational overhead.
If the answer is two to three teams, evaluate whether your current pain is actually deployment serialization or just organizational discipline. Many teams that think they need micro-frontends actually need a better release process and clearer code ownership conventions. Try that first.
If the answer is four or more teams, or you are running a platform where external partners need to embed UI components with deployment independence, micro-frontends start to make sense. The deployment autonomy benefit begins to outweigh the operational cost around this threshold.
For incremental migration scenarios, evaluate server-side composition first. It has the best performance profile and does not require the legacy and new applications to share a JavaScript module graph.
For teams that want Module Federation: start with a single remote. Validate the developer experience, deployment pipeline, type-sharing mechanism, and error handling before decomposing further. Teams that decompose too aggressively too quickly end up with a distributed monolith: all the complexity of micro-frontends with none of the autonomy, because every change still requires coordinating across repositories.
Closing
Micro-frontends solve a real problem. That problem is organizational, not technical. The architecture earns its complexity only when team autonomy and deployment independence are genuinely constrained by a shared codebase. When those conditions are present, Module Federation with proper singleton configuration, typed remote contracts, error boundaries at every mount point, and a disciplined approach to shared state gives you a system that scales with your organization. When those conditions are not present, you are adding infrastructure to solve a coordination problem that a conversation could fix instead.
More in Web Engineering
How Rspack Works Internally: Webpack-Compatible Module Graph, Rust Compilation Pipeline, and the Incremental Build Architecture Behind the Fastest Webpack Replacement
A deep dive into Rspack's Rust-based architecture, module graph construction, SWC transformation pipeline, webpack compatibility layer, incremental compilation, and what the tradeoffs look like for teams migrating from webpack.
How Turbopack Works Internally: Incremental Computation, Function-Level Caching, and the Rust-Based Bundler Architecture Behind Next.js
A deep dive into Turbopack's architecture covering the Turbo engine's incremental computation model, function-level caching, Rust-based module resolution, granular HMR invalidation, SWC integration, persistent caching, and how it compares to webpack, Vite, esbuild, and Rspack.
How gRPC Works: Protocol Buffers, HTTP/2 Multiplexing, and Bidirectional Streaming From Service Definition to Wire Format
A deep dive into gRPC internals: Protocol Buffer IDL and the buf codegen pipeline, HTTP/2 stream multiplexing and HPACK header compression, all four RPC patterns with TypeScript, channel management, deadline propagation, interceptors, load balancing from pick-first to xDS, the health checking protocol, and production tradeoffs vs REST and GraphQL.
How Node.js Works Internally: The Event Loop, libuv Thread Pool, and Async I/O Architecture From require() to Process Exit
A deep dive into Node.js internals covering V8 JIT compilation, the six-phase libuv event loop, thread pool mechanics, microtask queue priority, async/await desugaring, CommonJS versus ESM module resolution, and production tuning considerations with TypeScript examples.