Webpack Module Federation: How It Works Internally
Understand how Webpack Module Federation loads remote modules at runtime, negotiates shared dependency versions, and powers micro-frontend architectures.
Tags
Webpack Module Federation: How It Works Internally
Webpack Module Federation is a runtime module-sharing system built into Webpack 5 that allows independently built and deployed applications to share JavaScript modules over the network without rebuilding or redeploying consumers. Internally, it works through a container interface: each application exposes a global container object that other applications can call to asynchronously load specific modules. When a remote module is requested, the runtime fetches the remote application's entry file, initializes its shared scope (negotiating which versions of shared dependencies to use), and then loads the requested module through the container API. This mechanism is the foundation of micro-frontend architectures where teams independently develop, build, and deploy parts of a larger application.
TL;DR
Module Federation works through runtime containers that expose and consume modules across application boundaries. The shared scope negotiates dependency versions using semver. Each remote publishes a remoteEntry.js file that acts as a manifest and module loader. Understanding the container interface, shared scope initialization, and version negotiation is key to debugging federation issues.
Why This Matters
Modern web applications are built by multiple teams, often spanning different organizations, release cadences, and technology preferences. The traditional approach—building everything into a single bundle—creates deployment bottlenecks. If Team A's header component shares a build pipeline with Team B's checkout flow, a bug in the header blocks the checkout deployment.
Module Federation eliminates this coupling at the build level. Each team owns their build pipeline, deploys independently, and exposes modules that other teams consume at runtime. The checkout page imports the header component from Team A's deployed application, loading it dynamically when the page renders.
But using Module Federation effectively requires understanding what happens beneath the configuration surface. When things break—and in distributed systems, they will—you need to know about container initialization order, shared scope conflicts, version negotiation failures, and singleton constraint violations.
How It Works
The Container Interface
Every Webpack build configured with Module Federation generates a container. A container is a runtime object that provides two key capabilities:
- ›
get(module)— Returns a factory function for a specific exposed module - ›
init(shareScope)— Initializes the container with a shared scope for dependency negotiation
When Webpack builds your application with the ModuleFederationPlugin, it generates a remoteEntry.js file. This file, when loaded, registers a container on the global scope (typically window).
// What remoteEntry.js conceptually does (simplified)
var appShell = {
get(module) {
// Returns a promise that resolves to a factory function
return Promise.resolve().then(() => {
switch (module) {
case "./Header":
return () => require("./src/components/Header");
case "./Footer":
return () => require("./src/components/Footer");
}
});
},
init(shareScope) {
// Register shared dependencies in the shared scope
// Negotiate versions with other containers
},
};
window["appShell"] = appShell;Configuration: Expose and Remotes
The ModuleFederationPlugin configuration defines what a build exposes and what remotes it consumes.
// webpack.config.js - Shell Application
const { ModuleFederationPlugin } = require("webpack").container;
module.exports = {
plugins: [
new ModuleFederationPlugin({
name: "shell",
remotes: {
catalog: "catalog@https://catalog.example.com/remoteEntry.js",
checkout: "checkout@https://checkout.example.com/remoteEntry.js",
},
shared: {
react: { singleton: true, requiredVersion: "^18.0.0" },
"react-dom": { singleton: true, requiredVersion: "^18.0.0" },
"@tanstack/react-query": {
singleton: true,
requiredVersion: "^5.0.0",
},
},
}),
],
};// webpack.config.js - Catalog Micro-Frontend
module.exports = {
plugins: [
new ModuleFederationPlugin({
name: "catalog",
filename: "remoteEntry.js",
exposes: {
"./ProductList": "./src/components/ProductList",
"./ProductDetail": "./src/components/ProductDetail",
"./useProducts": "./src/hooks/useProducts",
},
shared: {
react: { singleton: true, requiredVersion: "^18.0.0" },
"react-dom": { singleton: true, requiredVersion: "^18.0.0" },
"@tanstack/react-query": {
singleton: true,
requiredVersion: "^5.0.0",
},
},
}),
],
};The name property defines the global variable name for the container. The remotes property tells the shell where to find other applications' entry files. The exposes property declares which modules are available for remote consumption.
Runtime Module Loading
When the shell application imports a remote module, this is what happens at runtime:
1. Shell encounters: import("catalog/ProductList")
2. Webpack runtime resolves "catalog" → external remote
3. Script tag is injected for https://catalog.example.com/remoteEntry.js
4. remoteEntry.js executes, registering window["catalog"] container
5. Shell calls: window["catalog"].init(shareScope)
→ Shared dependencies are negotiated (React, React DOM, etc.)
→ Compatible versions are resolved using semver
6. Shell calls: window["catalog"].get("./ProductList")
→ Returns a factory function for the ProductList module
7. Factory executes, returning the module exports
→ Uses negotiated shared dependencies (not its own bundled copy)
In code, consuming a remote module looks like a normal dynamic import:
// Shell application - consuming remote modules
import React, { lazy, Suspense } from "react";
const RemoteProductList = lazy(() => import("catalog/ProductList"));
const RemoteCheckout = lazy(() => import("checkout/CheckoutForm"));
function App() {
return (
<Suspense fallback={<div>Loading...</div>}>
<RemoteProductList category="electronics" />
<RemoteCheckout cartId="abc-123" />
</Suspense>
);
}TypeScript needs type declarations for remote modules since they do not exist at build time:
// types/remotes.d.ts
declare module "catalog/ProductList" {
const ProductList: React.ComponentType<{ category: string }>;
export default ProductList;
}
declare module "catalog/useProducts" {
export function useProducts(category: string): {
products: Product[];
loading: boolean;
};
}
declare module "checkout/CheckoutForm" {
const CheckoutForm: React.ComponentType<{ cartId: string }>;
export default CheckoutForm;
}Shared Scope and Version Negotiation
The shared scope is the runtime mechanism that prevents loading multiple copies of the same dependency. When a container is initialized, it registers its shared dependencies in the shared scope along with version metadata.
The negotiation follows these rules:
- ›
Singleton constraint: When
singleton: true, only one version of the dependency can be loaded. The runtime picks the highest version that satisfies allrequiredVersionconstraints. If a loaded singleton does not satisfy a consumer'srequiredVersion, Webpack emits a console warning but uses the singleton anyway (unlessstrictVersion: trueis set, which throws an error). - ›
Version compatibility: Without the singleton constraint, multiple versions can coexist. Each consumer gets the version it requested, potentially loading the same library multiple times.
- ›
Eager loading: By default, shared dependencies are loaded lazily. Setting
eager: trueincludes the dependency in the initial chunk rather than loading it asynchronously. This is useful for the host application but should be avoided in remotes.
// Shared configuration with detailed version control
shared: {
react: {
singleton: true, // Only one copy in the page
requiredVersion: "^18.2.0", // Must satisfy this range
strictVersion: false, // Warn on mismatch, don't crash
eager: false, // Load lazily (default)
},
lodash: {
singleton: false, // Multiple versions OK
requiredVersion: "^4.17.0",
},
"@company/design-system": {
singleton: true,
requiredVersion: "^3.0.0",
strictVersion: true, // Crash if version doesn't match
},
}You can inspect the shared scope at runtime in the browser console:
// Browser console debugging
console.log(__webpack_share_scopes__);
// Shows all registered shared dependencies, their versions,
// and which container provided themFallback Strategies
When a remote fails to load (network error, deployment issue, DNS failure), the entire host application can break. Robust federation requires fallback strategies.
// Error boundary for remote modules
import React, { Component, ReactNode } from "react";
interface Props {
fallback: ReactNode;
children: ReactNode;
}
interface State {
hasError: boolean;
}
class RemoteErrorBoundary extends Component<Props, State> {
state: State = { hasError: false };
static getDerivedStateFromError(): State {
return { hasError: true };
}
componentDidCatch(error: Error) {
console.error("Remote module failed to load:", error);
// Report to monitoring service
}
render() {
if (this.state.hasError) {
return this.props.fallback;
}
return this.props.children;
}
}
// Usage with fallback UI
function App() {
return (
<RemoteErrorBoundary fallback={<LocalProductList />}>
<Suspense fallback={<Skeleton />}>
<RemoteProductList category="electronics" />
</Suspense>
</RemoteErrorBoundary>
);
}For critical modules, implement retry logic at the script loading level:
// Custom remote loading with retry
function loadRemoteWithRetry(
url: string,
retries: number = 3
): Promise<void> {
return new Promise((resolve, reject) => {
function attempt(remaining: number) {
const script = document.createElement("script");
script.src = `${url}?t=${Date.now()}`; // Cache bust on retry
script.onload = () => resolve();
script.onerror = () => {
script.remove();
if (remaining > 0) {
setTimeout(() => attempt(remaining - 1), 1000);
} else {
reject(new Error(`Failed to load remote: ${url}`));
}
};
document.head.appendChild(script);
}
attempt(retries);
});
}Practical Implementation
Development Workflow
In development, each micro-frontend runs on its own dev server. The shell application points to local dev server URLs:
// webpack.config.dev.js
remotes: {
catalog: "catalog@http://localhost:3001/remoteEntry.js",
checkout: "checkout@http://localhost:3002/remoteEntry.js",
}
// webpack.config.prod.js
remotes: {
catalog: "catalog@https://catalog.prod.example.com/remoteEntry.js",
checkout: "checkout@https://checkout.prod.example.com/remoteEntry.js",
}For teams that want runtime remote URL configuration (avoiding hardcoded URLs in Webpack config), use the promise-based remote syntax:
remotes: {
catalog: `promise new Promise(resolve => {
const remoteUrl = window.__REMOTE_CONFIG__?.catalog
|| 'https://catalog.example.com/remoteEntry.js';
const script = document.createElement('script');
script.src = remoteUrl;
script.onload = () => resolve(window.catalog);
document.head.appendChild(script);
})`,
}Debugging Federation Issues
Common issues and how to diagnose them:
"Shared module is not available for eager consumption" — This happens when the host application tries to use a shared dependency synchronously before the shared scope is initialized. The fix is to use an async entry point:
// bootstrap.js (async entry)
import("./App").then(({ default: App }) => {
ReactDOM.createRoot(document.getElementById("root")).render(<App />);
});
// index.js (synchronous entry - what Webpack loads)
import("./bootstrap");Multiple copies of React loading — Check the Network tab for duplicate React bundles. Verify that all remotes and the host declare React as a singleton in their shared configuration. Inspect __webpack_share_scopes__ to see which container provided React.
Remote module returns undefined — The exposed module path must match exactly. If the remote exposes "./ProductList" but you import "catalog/productList" (case mismatch), the container's get() call returns undefined.
Stale remote entry — Browsers cache remoteEntry.js. In production, include a content hash in the filename or use cache-control headers that force revalidation. CDN caching can also serve stale entry files after a deployment.
Common Pitfalls
Sharing too many dependencies. Every shared dependency adds negotiation overhead and complexity. Share only dependencies that cause issues when duplicated (React, framework singletons, shared state libraries). Let utilities like lodash or date-fns be bundled independently.
Tight coupling between remotes. If Remote A imports types or utilities from Remote B, you have recreated the monolith at the federation level. Keep remote contracts minimal—expose components and hooks, not internal utilities.
No versioning strategy. When Team A changes the props of an exposed component, consumers break silently. Establish semantic versioning for exposed module interfaces and communicate breaking changes through the same channels you use for API changes.
Ignoring CSS isolation. Federated modules share a single DOM. CSS from one remote can affect another. Use CSS Modules, CSS-in-JS, or a naming convention that prevents style collisions.
Testing only in integration. Each micro-frontend should be testable in isolation. If it only works when loaded through the shell, you have coupled it too tightly. Mock the shared scope in tests to simulate the federation environment.
When to Use (and When Not To)
Use Module Federation when:
- ›Multiple teams need to deploy independently on different release cadences
- ›The application is large enough that a single build pipeline creates bottlenecks
- ›You need to share live, always-up-to-date components across multiple applications
- ›Different parts of the application may use different framework versions during migrations
Avoid Module Federation when:
- ›A single team owns the entire application
- ›The application is small enough to build and deploy as a monolith in seconds
- ›All teams deploy simultaneously anyway
- ›You can achieve code sharing through a published npm package with acceptable rebuild times
- ›Your team is not prepared for the operational complexity of distributed frontend systems
FAQ
What is Webpack Module Federation? Module Federation is a Webpack 5 feature that allows multiple independently built and deployed JavaScript applications to share code at runtime. Each application can expose modules for others to consume and declare remote modules it wants to import. The sharing happens through a runtime container interface that loads code over the network, eliminating the need to rebuild consumers when shared code changes.
How does Module Federation handle different versions of shared dependencies? Module Federation uses a shared scope and version negotiation protocol. When multiple remotes declare the same dependency with different versions, the runtime selects the highest version that satisfies all consumers' semver ranges. For singleton dependencies like React, only one version loads and all consumers use it. If no compatible version exists, the module falls back to loading its own bundled copy.
What is the difference between Module Federation and npm packages? NPM packages are resolved and bundled at build time. Changing a shared library requires rebuilding and redeploying every consumer application. Module Federation resolves modules at runtime over the network, so updating a shared component in one deployed application is immediately available to all consumers without any rebuild or redeployment step.
Can Module Federation work with different frameworks? Yes, Module Federation is framework-agnostic at the Webpack level. You can federate React, Vue, Angular, or vanilla JavaScript modules within the same page. However, sharing framework instances requires careful version negotiation to avoid loading multiple copies of the same framework, which wastes memory and can cause runtime errors with frameworks that expect a single instance.
How do I debug Module Federation issues?
Start with the browser Network tab to verify remote entry files load correctly and check their HTTP status codes. Examine the console for shared scope negotiation errors and version mismatch warnings. Use __webpack_share_scopes__ in the browser console to inspect loaded dependencies and their versions. Enable Webpack stats output at build time to review federation metadata and verify exposed modules match expected paths.
Collaboration
Need help with a project?
Let's Build It
I help startups and established companies design, build, and scale world-class digital products. From deep technical architecture to pixel-perfect UI — let's bring your vision to life.
Related Articles
How to Design API Contracts Between Micro-Frontends and BFFs
Learn how to design stable API contracts between Micro-Frontends and Backend-for-Frontend layers with versioning, ownership boundaries, error handling, and schema governance.
Next.js BFF Architecture
An architectural deep dive into using Next.js as a Backend-for-Frontend, including route handlers, server components, auth boundaries, caching, and service orchestration.
Next.js Cache Components and PPR in Real Apps
A practical guide to using Next.js Cache Components and Partial Prerendering in real applications, with tradeoffs, cache strategy, and freshness considerations.