Module Federation v2
Table of Contents
- Version Comparison (v1 vs v1.5 vs v2)
- Rsbuild Configuration
- Shared Dependencies Strategy
- Dynamic Remote Loading
- TypeScript Type Generation
- mf-manifest.json Protocol
- Async Bootstrap Requirement
- Error Boundary Patterns
- Common Gotchas and Troubleshooting
- References
Version Comparison (v1 vs v1.5 vs v2)
Module Federation has evolved significantly since its introduction in webpack 5. Understanding the lineage is critical when evaluating documentation, blog posts, and community examples, as advice for v1 often does not apply to v2.
| Feature | v1 (webpack 5 native) | v1.5 (community / @module-federation/enhanced) | v2 (Rspack-native, official successor) |
|---|---|---|---|
| Bundler | webpack 5 only | webpack 5 (plugin-based) | Rspack / Rsbuild (first-class), webpack 5 supported |
| Remote Entry | Static remoteEntry.js with fixed URL at build time | Static or dynamic; manifest-based discovery | Dynamic via mf-manifest.json; static fallback supported |
| Runtime API | None; purely declarative via plugin config | @module-federation/enhanced/runtime — init(), loadRemote(), registerRemotes() | Same runtime API, stabilized and extended |
| Shared Dependency Negotiation | Webpack internal scope-based resolution | Runtime-negotiated via manifest metadata | Runtime-negotiated; improved version range matching |
| Type Hints | None | Experimental dts plugin; types generated at build time | Stable dts plugin; types served alongside manifest and auto-fetched by host |
| DevTools | None | Basic logging | Chrome DevTools plugin for inspecting federation graph, shared deps, loaded remotes |
| Manifest Protocol | N/A — only remoteEntry.js | mf-manifest.json introduced | mf-manifest.json standardized; includes exposed modules, shared deps, type URLs |
| Dynamic Remote Registration | Not supported natively | registerRemotes() at runtime | registerRemotes() with hot-update support |
| Build Performance | webpack 5 speeds | webpack 5 speeds | Rspack (Rust-based) — 5-10x faster builds |
| HMR for Remotes | Not supported | Partial; often requires full reload | Significantly improved in 2.0; intra-remote HMR works reliably, cross-remote HMR still requires full page reload in some configurations |
| Monorepo Support | Manual configuration | Improved plugin defaults | First-class with Rsbuild project references |
Key takeaway: v2 is not a clean break — it is the stabilization of the v1.5 community work under the @module-federation/enhanced package, with Rspack as the primary bundler. The npm package name remains @module-federation/enhanced; "v2" refers to the protocol and runtime generation, not a separate package.
Note on versioning: The label "Module Federation v2" (or "MF v2") historically referred to the architectural version (the protocol and runtime generation), while the npm package
@module-federation/enhancedused 0.x version numbers. As of the 2.0.1 release, the npm package major version now aligns with the architectural version — both are "v2". When this document refers to "v2", it means the architecture and@module-federation/enhanced@^2.0.1.
Rsbuild Configuration
Host (Shell App) Configuration
The host (shell) application is the entry point that users navigate to. It loads remote MFEs at runtime based on route or feature-flag configuration. The host declares which remotes it may consume but resolves their actual URLs dynamically.
// apps/shell/rsbuild.config.ts
import { defineConfig } from '@rsbuild/core';
import { pluginReact } from '@rsbuild/plugin-react';
import { ModuleFederationPlugin } from '@module-federation/enhanced/rspack';
export default defineConfig({
plugins: [pluginReact()],
server: {
port: 3000,
},
dev: {
// Required for loading remotes from different dev server ports
assetPrefix: 'http://localhost:3000',
},
output: {
// In production, assets are served from a CDN via Cloudflare Workers
assetPrefix: process.env.CDN_BASE_URL || '/',
},
tools: {
rspack: (config, { appendPlugins }) => {
appendPlugins(
new ModuleFederationPlugin({
name: 'shell',
// Remotes are NOT statically declared here for production.
// They are registered at runtime via `registerRemotes()` using
// version config fetched from Cloudflare KV.
//
// For local development, static remotes can be declared:
remotes:
process.env.NODE_ENV === 'development'
? {
mfe_dashboard: 'mfe_dashboard@http://localhost:3001/mf-manifest.json',
mfe_settings: 'mfe_settings@http://localhost:3002/mf-manifest.json',
mfe_analytics: 'mfe_analytics@http://localhost:3003/mf-manifest.json',
}
: {},
// Shared dependencies — must match remote configs exactly
shared: {
react: {
singleton: true,
requiredVersion: '^19.2.4',
eager: false,
},
'react-dom': {
singleton: true,
requiredVersion: '^19.2.4',
eager: false,
},
'react-router-dom': {
singleton: true,
requiredVersion: '^7.13.1',
eager: false,
},
// State management — shared to ensure single store instance
zustand: {
singleton: true,
requiredVersion: '^5.0.11',
},
},
// Enable type generation fetching from remotes
dts: {
consumeTypes: {
// Directory where fetched remote types are written
remoteTypesFolder: '@mf-types',
},
},
// Runtime plugins for telemetry, error reporting, etc.
runtimePlugins: ['./src/mf-runtime-plugins/telemetry.ts'],
})
);
},
},
});
Notes on the host configuration:
remotesis intentionally empty in production. The shell fetches a version config from Cloudflare KV at startup and registers remotes dynamically via the runtime API. This decouples MFE deployment from shell deployment.eager: falseon React/ReactDOM is critical. Eager loading in the host forces synchronous initialization and defeats the async bootstrap pattern. The one exception is if the host must render before any remote is loaded (see Async Bootstrap Requirement).dts.consumeTypestells the build to pull.d.tstarballs published by each remote, giving the host compile-time type safety for remote modules.
Remote (MFE) Configuration
Each MFE is a standalone application that can run independently during development but is loaded into the shell in production. The remote configuration declares which modules it exposes and how it shares dependencies with the host.
// apps/mfe-dashboard/rsbuild.config.ts
import { defineConfig } from '@rsbuild/core';
import { pluginReact } from '@rsbuild/plugin-react';
import { ModuleFederationPlugin } from '@module-federation/enhanced/rspack';
import packageJson from './package.json';
export default defineConfig({
plugins: [pluginReact()],
server: {
port: 3001,
// Allow the shell to load this remote during local development
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
},
},
dev: {
assetPrefix: 'http://localhost:3001',
},
output: {
// Each MFE deploys to its own CDN path
// e.g., https://cdn.example.com/mfe-dashboard/v1.2.3/
assetPrefix: process.env.CDN_BASE_URL || '/',
// Clean output directory on each build
cleanDistPath: true,
},
tools: {
rspack: (config, { appendPlugins }) => {
appendPlugins(
new ModuleFederationPlugin({
name: 'mfe_dashboard',
// Modules exposed to the host and other remotes
exposes: {
'./App': './src/App.tsx',
'./DashboardWidget': './src/components/DashboardWidget.tsx',
'./hooks/useDashboardData': './src/hooks/useDashboardData.ts',
},
// Shared dependencies — must be compatible with host config
shared: {
react: {
singleton: true,
requiredVersion: '^19.2.4',
eager: false,
},
'react-dom': {
singleton: true,
requiredVersion: '^19.2.4',
eager: false,
},
'react-router-dom': {
singleton: true,
requiredVersion: '^7.13.1',
eager: false,
},
zustand: {
singleton: true,
requiredVersion: '^5.0.11',
},
},
// Generate mf-manifest.json for runtime discovery
manifest: {
filePath: 'static',
},
// Generate TypeScript declarations for exposed modules
dts: {
generateTypes: {
// Exposed modules will have .d.ts files generated
// and bundled into a tarball served alongside the manifest
extractThirdParty: true,
extractRemoteTypes: true,
},
},
})
);
},
},
});
Notes on the remote configuration:
manifest.filePath: "static"outputs themf-manifest.jsonto the static assets directory, making it servable from the CDN alongside the chunks. The Cloudflare Worker proxies requests to this manifest at the expected URL.exposesuses the"./ModuleName"convention. The host loads these asloadRemote("mfe_dashboard/App"). The leading./in the key is required by the plugin but stripped in the runtime API call.dts.generateTypesproduces a@mf-types.d.ts.tgz(or individual.d.tsfiles, depending on configuration) that the host'sdts.consumeTypespulls at build time.- The
namefield must be a valid JavaScript identifier (no hyphens). Use underscores:mfe_dashboard, notmfe-dashboard.
Shared Dependencies Strategy
Shared dependency configuration is the most error-prone aspect of Module Federation. Misconfiguration leads to duplicate React instances (breaking hooks), version mismatches, or runtime crashes.
Core Principles
React and ReactDOM as strict singletons:
shared: {
react: {
singleton: true, // Only one instance allowed at runtime
requiredVersion: '^19.2.4', // Semver range the MFE was built against
eager: false, // Loaded async (required for proper bootstrap)
},
'react-dom': {
singleton: true,
requiredVersion: '^19.2.4',
eager: false,
},
}
When singleton: true, the Module Federation runtime guarantees that only one copy of the library is loaded. If multiple remotes provide different versions, the runtime selects the highest version that satisfies all requiredVersion constraints. If no single version satisfies all constraints, a console warning is emitted and the highest available version is used anyway (because singleton forces a single copy).
Warning — singleton version mismatch: When multiple remotes declare a shared singleton dependency at different
requiredVersionranges, Module Federation silently uses the highest available version at runtime. There is no build-time error — only a console warning. This can cause subtle runtime issues if APIs changed between the versions (e.g., a remote built againstzustand@^4.5.0running withzustand@5.0.11loaded by another remote). Recommendation: enforce identicalrequiredVersionranges across all remotes and the host. Add a CI lint step that comparessharedconfigs across allrsbuild.config.tsfiles in the monorepo to catch drift early.
Design system as a shared singleton with MF remote fallback:
The internal design system (@acme/design-system) is shared as a singleton so all MFEs render consistently. It is also exposed as an MF remote so that the shell can serve the canonical version:
// In the shell's shared config:
shared: {
'@acme/design-system': {
singleton: true,
requiredVersion: '^3.0.0',
eager: false,
},
}
This ensures all MFEs use the same design-system instance. If an MFE bundles a version that falls outside the range, the shell's version takes precedence (singleton behavior).
Eager vs Lazy Loading Tradeoffs
| Strategy | Behavior | Use Case |
|---|---|---|
eager: false (default) | Shared dependency is loaded asynchronously when first needed | Standard for all remotes and the host |
eager: true | Shared dependency is bundled into the initial chunk and loaded synchronously | Only use when the host must render before any async boundary (rare) |
Recommendation: Always use eager: false. The async bootstrap pattern (see below) handles the loading sequence correctly. Setting eager: true in a remote will cause it to bundle its own copy of the dependency, defeating the purpose of sharing.
Version Mismatch Handling
When an MFE declares requiredVersion: "^19.2.4" but the shell provides React 19.1.0:
- Singleton mode: The runtime uses the shell's
19.1.0because singleton forces a single copy. A warning is printed to the console:Unsatisfied version 19.1.0 of shared singleton module react (required ^19.2.4). - Non-singleton mode: The MFE loads its own bundled copy of
19.2.x, resulting in two React instances. This will break hooks and context.
Recommended version strategy:
- Pin the major version across all MFEs and the shell.
- Allow minor and patch drift — use
^19.2.4, not19.2.4. - Run a CI check that compares
requiredVersionranges across allrsbuild.config.tsfiles in the monorepo. - When upgrading React major versions, coordinate a flag-day across all MFEs or use the version config to gate rollout.
Dynamic Remote Loading
Dynamic remote loading is the foundation of independent MFE deployment. The shell does not hardcode remote URLs — it fetches a version config at startup and registers remotes on the fly.
Full Bootstrap Pattern
// apps/shell/src/mf-runtime/init.ts
import { init, loadRemote, registerRemotes } from '@module-federation/enhanced/runtime';
/**
* Version config fetched from Cloudflare KV at startup.
* Maps MFE names to their manifest URLs.
*
* Example:
* {
* "mfe_dashboard": "https://cdn.example.com/mfe-dashboard/v1.2.3/mf-manifest.json",
* "mfe_settings": "https://cdn.example.com/mfe-settings/v2.0.1/mf-manifest.json",
* "mfe_analytics": "https://cdn.example.com/mfe-analytics/v0.9.0/mf-manifest.json"
* }
*/
export interface VersionConfig {
[mfeName: string]: string; // MFE name → manifest URL
}
let initialized = false;
/**
* Initialize the Module Federation runtime with remotes from the version config.
* This must be called once before any `loadRemote()` call.
*/
export async function initializeFederation(versionConfig: VersionConfig): Promise<void> {
if (initialized) {
console.warn('[MF] Federation already initialized. Use updateRemotes() to change remotes.');
return;
}
const remotes = Object.entries(versionConfig).map(([name, entry]) => ({
name,
entry,
}));
init({
name: 'shell',
remotes,
shared: {
react: {
version: '19.2.4',
scope: 'default',
lib: () => require('react'),
shareConfig: {
singleton: true,
requiredVersion: '^19.2.4',
},
},
'react-dom': {
version: '19.2.4',
scope: 'default',
lib: () => require('react-dom'),
shareConfig: {
singleton: true,
requiredVersion: '^19.2.4',
},
},
},
});
initialized = true;
}
/**
* Update or add remotes at runtime without re-initializing.
* Useful for feature flags that enable/disable MFEs dynamically.
*/
export function updateRemotes(versionConfig: Partial<VersionConfig>): void {
const remotes = Object.entries(versionConfig).map(([name, entry]) => ({
name,
entry,
}));
registerRemotes(remotes, { force: true });
}
Loading a Remote Component
// apps/shell/src/mf-runtime/loadMfe.ts
import { loadRemote } from '@module-federation/enhanced/runtime';
import type { ComponentType } from 'react';
interface RemoteModule {
default: ComponentType<Record<string, unknown>>;
}
/**
* Load a remote module by its federated name.
*
* @param remoteName - e.g., "mfe_dashboard/App"
* @returns The default export of the remote module (typically a React component)
*/
export async function loadMfe<T = ComponentType<Record<string, unknown>>>(
remoteName: string
): Promise<T> {
const module = await loadRemote<RemoteModule>(remoteName);
if (!module) {
throw new Error(`[MF] Failed to load remote module: ${remoteName}`);
}
return module.default as T;
}
React Component Wrapper with Suspense and ErrorBoundary
// apps/shell/src/components/RemoteComponent.tsx
import React, { Suspense, useCallback, useEffect, useRef, useState } from 'react';
import type { ComponentType } from 'react';
import { loadMfe } from '../mf-runtime/loadMfe';
interface RemoteComponentProps {
/** Federated module name, e.g., "mfe_dashboard/App" */
remoteName: string;
/** Props to pass through to the loaded remote component */
componentProps?: Record<string, unknown>;
/** Fallback UI shown while loading */
loadingFallback?: React.ReactNode;
/** Fallback UI shown on error */
errorFallback?: React.ReactNode;
/** Maximum number of retry attempts on load failure */
maxRetries?: number;
/** Delay in ms between retries (doubles on each attempt) */
retryDelayMs?: number;
}
interface ErrorBoundaryState {
hasError: boolean;
error: Error | null;
}
class RemoteErrorBoundary extends React.Component<
{
children: React.ReactNode;
fallback: React.ReactNode;
onError?: (error: Error) => void;
onRetry?: () => void;
},
ErrorBoundaryState
> {
constructor(props: RemoteErrorBoundary['props']) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void {
console.error('[MF] Remote component error:', error, errorInfo);
this.props.onError?.(error);
}
reset = (): void => {
this.setState({ hasError: false, error: null });
this.props.onRetry?.();
};
render(): React.ReactNode {
if (this.state.hasError) {
return (
<div role="alert">
{this.props.fallback || (
<div style={{ padding: '16px', border: '1px solid #e53e3e', borderRadius: '8px' }}>
<h3>Failed to load module</h3>
<p>{this.state.error?.message}</p>
<button onClick={this.reset}>Retry</button>
</div>
)}
</div>
);
}
return this.props.children;
}
}
/**
* Wrapper component that dynamically loads a federated remote module,
* handles loading states, errors, and retries.
*/
export function RemoteComponent({
remoteName,
componentProps = {},
loadingFallback,
errorFallback,
maxRetries = 3,
retryDelayMs = 1000,
}: RemoteComponentProps): React.ReactElement {
const [LoadedComponent, setLoadedComponent] = useState<ComponentType<Record<string, unknown>> | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const retryCountRef = useRef(0);
const loadComponent = useCallback(async () => {
setLoading(true);
setError(null);
try {
const Component = await loadMfe(remoteName);
setLoadedComponent(() => Component);
} catch (err) {
const loadError = err instanceof Error ? err : new Error(String(err));
if (retryCountRef.current < maxRetries) {
retryCountRef.current += 1;
const delay = retryDelayMs * Math.pow(2, retryCountRef.current - 1);
console.warn(
`[MF] Retry ${retryCountRef.current}/${maxRetries} for ${remoteName} in ${delay}ms`
);
setTimeout(() => {
loadComponent();
}, delay);
return;
}
setError(loadError);
} finally {
setLoading(false);
}
}, [remoteName, maxRetries, retryDelayMs]);
useEffect(() => {
retryCountRef.current = 0;
loadComponent();
}, [loadComponent]);
const handleRetry = useCallback(() => {
retryCountRef.current = 0;
loadComponent();
}, [loadComponent]);
if (loading) {
return <>{loadingFallback || <div>Loading module...</div>}</>;
}
if (error || !LoadedComponent) {
return (
<div role="alert">
{errorFallback || (
<div style={{ padding: '16px', border: '1px solid #e53e3e', borderRadius: '8px' }}>
<h3>Failed to load {remoteName}</h3>
<p>{error?.message || 'Unknown error'}</p>
<button onClick={handleRetry}>Retry</button>
</div>
)}
</div>
);
}
return (
<RemoteErrorBoundary
fallback={errorFallback}
onError={(err) => console.error(`[MF] Runtime error in ${remoteName}:`, err)}
onRetry={handleRetry}
>
<Suspense fallback={loadingFallback || <div>Loading...</div>}>
<LoadedComponent {...componentProps} />
</Suspense>
</RemoteErrorBoundary>
);
}
Usage in Shell Routing
// apps/shell/src/App.tsx
import { Routes, Route } from 'react-router-dom';
import { RemoteComponent } from './components/RemoteComponent';
import { ShellLayout } from './components/ShellLayout';
import { ModuleLoadingSkeleton } from './components/ModuleLoadingSkeleton';
export function App(): React.ReactElement {
return (
<ShellLayout>
<Routes>
<Route
path="/dashboard/*"
element={
<RemoteComponent
remoteName="mfe_dashboard/App"
loadingFallback={<ModuleLoadingSkeleton />}
maxRetries={3}
retryDelayMs={500}
/>
}
/>
<Route
path="/settings/*"
element={
<RemoteComponent
remoteName="mfe_settings/App"
loadingFallback={<ModuleLoadingSkeleton />}
/>
}
/>
<Route
path="/analytics/*"
element={
<RemoteComponent
remoteName="mfe_analytics/App"
loadingFallback={<ModuleLoadingSkeleton />}
/>
}
/>
</Routes>
</ShellLayout>
);
}
TypeScript Type Generation
Module Federation v2 solves one of the most painful aspects of micro frontends: type safety across independently deployed applications.
How It Works
- At build time, the remote MFE's
dts.generateTypesconfiguration extracts TypeScript declarations for allexposesentries. - The generated
.d.tsfiles are bundled into a tarball (e.g.,@mf-types.zip) and placed alongside themf-manifest.jsonin the build output. - At the host's build time, the
dts.consumeTypesconfiguration downloads these tarballs from each remote's manifest URL and extracts them into a local@mf-types/directory. - The host's TypeScript compiler picks up these types via path mappings, giving full IntelliSense and type checking for remote modules.
Remote DTS Configuration
// In the remote's rsbuild.config.ts (within ModuleFederationPlugin)
dts: {
generateTypes: {
// Include types from third-party packages used in exposed APIs
extractThirdParty: true,
// Include types from other remotes if this MFE re-exports them
extractRemoteTypes: true,
// Compile only the exposed modules (faster than full project)
compilerInstance: 'tsc',
// Additional tsconfig options for type generation
tsConfigPath: './tsconfig.json',
},
},
Host DTS Configuration
// In the host's rsbuild.config.ts (within ModuleFederationPlugin)
dts: {
consumeTypes: {
// Directory where fetched remote types are written
remoteTypesFolder: '@mf-types',
// Automatically fetch types when building
consumeAPITypes: true,
},
},
tsconfig.json Path Mappings
For development-time type resolution (before the build fetches types), configure path aliases:
// apps/shell/tsconfig.json
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
// Module Federation remote type mappings
"mfe_dashboard/*": ["./@mf-types/mfe_dashboard/*"],
"mfe_settings/*": ["./@mf-types/mfe_settings/*"],
"mfe_analytics/*": ["./@mf-types/mfe_analytics/*"],
// Shared types package from the monorepo
"@acme/shared-types": ["../../packages/shared-types/src/index.ts"]
}
},
"include": ["src", "@mf-types"]
}
Monorepo vs Cross-Repo Type Sharing
Monorepo (preferred): All MFEs live in the same repository. Types from packages/shared-types are imported directly. The @mf-types generation supplements this with types for the federated module boundaries.
Cross-repo: MFEs are in separate repositories. The dts.generateTypes output must be accessible to the host at build time. Options:
- CDN-served types (recommended): The generated type tarball is deployed alongside the manifest. The host's
consumeTypesfetches it over HTTPS. - npm package: Each MFE publishes a
@acme/mfe-dashboard-typespackage. This couples deployment to npm publish cycles and is not recommended. - Git submodules: Avoid. Adds complexity without clear benefits.
mf-manifest.json Protocol
The mf-manifest.json file is the core discovery mechanism in Module Federation v2. It replaces the opaque remoteEntry.js of v1 with a structured, inspectable manifest.
Manifest Structure
// Example: https://cdn.example.com/mfe-dashboard/v1.2.3/mf-manifest.json
{
"id": "mfe_dashboard",
"name": "mfe_dashboard",
"metaData": {
"name": "mfe_dashboard",
"type": "app",
"buildInfo": {
"buildVersion": "1.2.3",
"buildHash": "a1b2c3d4"
},
"remoteEntry": {
"name": "remoteEntry.js",
"path": "",
"type": "global"
},
"types": {
"path": "",
"name": "@mf-types.zip",
"api": "@mf-types/mfe_dashboard/apis.d.ts"
},
"globalName": "mfe_dashboard",
"pluginVersion": "2.0.1"
},
"shared": [
{
"id": "mfe_dashboard:react",
"name": "react",
"version": "19.2.4",
"requiredVersion": "^19.2.4",
"singleton": true,
"eager": false,
"assets": {
"js": { "async": ["__federation_shared/react.js"], "sync": [] },
"css": { "async": [], "sync": [] }
}
},
{
"id": "mfe_dashboard:react-dom",
"name": "react-dom",
"version": "19.2.4",
"requiredVersion": "^19.2.4",
"singleton": true,
"eager": false,
"assets": {
"js": { "async": ["__federation_shared/react-dom.js"], "sync": [] },
"css": { "async": [], "sync": [] }
}
}
],
"exposes": [
{
"id": "mfe_dashboard:./App",
"name": "./App",
"path": "./src/App.tsx",
"assets": {
"js": { "async": ["static/js/expose_App.js"], "sync": [] },
"css": { "async": ["static/css/expose_App.css"], "sync": [] }
}
},
{
"id": "mfe_dashboard:./DashboardWidget",
"name": "./DashboardWidget",
"path": "./src/components/DashboardWidget.tsx",
"assets": {
"js": { "async": ["static/js/expose_DashboardWidget.js"], "sync": [] },
"css": { "async": [], "sync": [] }
}
}
]
}
How the Runtime Uses the Manifest
- Discovery: When
init()orregisterRemotes()is called with a manifest URL, the runtime fetches the JSON. - Shared dependency negotiation: The runtime compares the
sharedentries across all loaded manifests and the host. It determines which version of each shared dependency to use, respectingsingleton,requiredVersion, and version precedence rules. - Module loading: When
loadRemote("mfe_dashboard/App")is called, the runtime looks up theexposesentry for./App, resolves the asset URLs relative to the manifest URL, and dynamically loads the JavaScript chunk. - Type resolution: The host build reads
metaData.typesto know where to fetch the.d.tstarball.
CDN Serving Considerations
The manifest and its referenced assets are served from a CDN (Cloudflare R2 behind Workers in this architecture). Key considerations:
- Manifest URL stability: The manifest URL includes the version (
/mfe-dashboard/v1.2.3/mf-manifest.json). The version config in KV points to the current version. This allows instant rollback by updating the KV entry. - Asset immutability: All JS/CSS chunks are content-hashed. They can be cached aggressively (
Cache-Control: public, max-age=31536000, immutable). - Manifest caching: The manifest itself should have a short TTL or use
no-cachewithETagvalidation, because the host fetches it on every page load to discover the current chunk URLs.
Warning — manifest freshness race condition: The
mf-manifest.jsoncan become stale if a remote is redeployed while the shell still holds a cached manifest (e.g., from a Service Worker cache, an aggressive CDN edge cache, or an in-memory cache from a long-lived browser tab). When this happens, the shell may attempt to load chunk URLs that no longer exist, causing 404 errors and broken MFEs. Mitigation: append a cache-busting query parameter to manifest URLs (e.g.,mf-manifest.json?t=<timestamp>ormf-manifest.json?v=<deployId>), use short TTLs (max-age=0, must-revalidate) on manifest responses, and ensure Service Workers do not aggressively cache manifests.
Cache-Control Headers
# mf-manifest.json — revalidate on every request
Cache-Control: public, max-age=0, must-revalidate
ETag: "a1b2c3d4"
# remoteEntry.js — revalidate (it references chunk URLs)
Cache-Control: public, max-age=0, must-revalidate
# JS/CSS chunks (content-hashed filenames)
Cache-Control: public, max-age=31536000, immutable
# @mf-types.zip — immutable per version
Cache-Control: public, max-age=31536000, immutable
Async Bootstrap Requirement
Module Federation requires an asynchronous entry point to correctly negotiate shared dependencies before any application code executes. This is the single most common source of "Shared module is not available for eager consumption" errors.
The Pattern
// apps/shell/src/index.ts
// This is the webpack/rspack entry point.
// It MUST be a dynamic import — nothing else.
import('./bootstrap');
// apps/shell/src/bootstrap.tsx
// This is where the actual application code lives.
import React from 'react';
import { createRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import { App } from './App';
import { initializeFederation } from './mf-runtime/init';
import { fetchVersionConfig } from './services/versionConfig';
async function main(): Promise<void> {
// 1. Fetch version config from Cloudflare Worker (backed by KV)
const versionConfig = await fetchVersionConfig();
// 2. Initialize Module Federation runtime with remote manifests
await initializeFederation(versionConfig);
// 3. Render the application
const container = document.getElementById('root');
if (!container) {
throw new Error('Root element not found');
}
const root = createRoot(container);
root.render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>
);
}
main().catch((err) => {
console.error('[Shell] Failed to bootstrap application:', err);
// Render a minimal error state without React (since React itself may have failed to load)
const root = document.getElementById('root');
if (root) {
root.innerHTML = `
<div style="padding: 32px; font-family: sans-serif;">
<h1>Application failed to load</h1>
<p>Please try refreshing the page. If the problem persists, contact support.</p>
<pre style="color: red;">${err.message}</pre>
</div>
`;
}
});
The same pattern applies to each remote MFE when it runs standalone:
// apps/mfe-dashboard/src/index.ts
import('./bootstrap');
// apps/mfe-dashboard/src/bootstrap.tsx
import React from 'react';
import { createRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import { App } from './App';
const container = document.getElementById('root');
if (container) {
const root = createRoot(container);
root.render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>
);
}
What Breaks Without Async Bootstrap
Without the import('./bootstrap') indirection, the following sequence occurs:
- Rspack evaluates the entry module synchronously.
- The entry module imports React (a shared dependency).
- Module Federation has not yet had a chance to check which version of React to use (host's or remote's).
- The shared dependency container is not initialized, so the import falls through to a local copy — or crashes with:
Uncaught Error: Shared module is not available for eager consumption.
The dynamic import() creates an async boundary that gives the Module Federation runtime time to:
- Load the remote entry scripts.
- Negotiate shared dependency versions.
- Set up the shared scope.
Only after this negotiation completes does bootstrap.tsx execute, and all import statements within it resolve against the negotiated shared scope.
Error Boundary Patterns
Remote components are loaded over the network from independently deployed services. They can fail for many reasons: network errors, CDN outages, incompatible code changes, or runtime exceptions in the remote code. Robust error handling is non-negotiable.
Layered Error Handling
The RemoteComponent wrapper shown in Dynamic Remote Loading implements three layers:
-
Load-time error handling: If
loadRemote()rejects (network error, 404, parse error), the component shows an error state with a retry button. Retries use exponential backoff. -
React ErrorBoundary: If the remote component loads successfully but throws during rendering, the ErrorBoundary catches it and displays a fallback. This handles runtime errors in remote code.
-
Suspense boundary: If the remote component uses
React.lazyor suspends for data, the Suspense boundary shows a loading state.
Retry Mechanism Details
// Retry configuration per remote, based on criticality
const REMOTE_RETRY_CONFIG: Record<string, { maxRetries: number; delayMs: number }> = {
// Dashboard is the primary view — retry aggressively
mfe_dashboard: { maxRetries: 5, delayMs: 500 },
// Settings is less critical — fewer retries
mfe_settings: { maxRetries: 2, delayMs: 1000 },
// Analytics can be degraded gracefully
mfe_analytics: { maxRetries: 1, delayMs: 2000 },
};
Production Error Reporting
// apps/shell/src/mf-runtime-plugins/telemetry.ts
import type { FederationRuntimePlugin } from '@module-federation/enhanced/runtime';
const telemetryPlugin: () => FederationRuntimePlugin = () => ({
name: 'telemetry-plugin',
errorLoadRemote({ id, error, from }) {
// Report to your observability platform
console.error(`[MF Telemetry] Failed to load remote: ${id}`, {
error: error?.message,
from,
timestamp: Date.now(),
});
// Example: send to an error tracking service
// errorTracker.captureException(error, { tags: { mfeId: id, loadFrom: from } });
// Return undefined to let the default error handling proceed,
// or return a fallback module to gracefully degrade
return undefined;
},
beforeInit(args) {
console.info('[MF Telemetry] Initializing federation runtime', {
name: args.options.name,
remotes: args.options.remotes?.map((r) => r.name),
});
return args;
},
afterResolve(args) {
console.debug(`[MF Telemetry] Resolved remote: ${args.id}`, {
pkgName: args.pkgName,
entry: args.remote?.entry,
});
return args;
},
});
export default telemetryPlugin;
Common Gotchas and Troubleshooting
Version Postfix Bug
Symptom: Shared dependency keys in the webpack/rspack internal scope have version numbers appended, e.g., react@19.2.4 instead of react. This causes the host and remote to fail to match shared dependencies, resulting in duplicate copies.
Cause: This occurs when the requiredVersion field resolves to a specific version (from package.json) instead of a semver range, and the bundler appends it as a key suffix.
Fix: Always set requiredVersion explicitly in the shared config with a semver range (^19.2.4), rather than relying on automatic inference from package.json:
// BAD: inferred from package.json, may resolve to exact version
shared: ['react', 'react-dom']
// GOOD: explicit range, consistent across host and remotes
shared: {
react: {
singleton: true,
requiredVersion: '^19.2.4',
},
}
Eager Consumption Errors
Symptom: Uncaught Error: Shared module is not available for eager consumption
Cause: Application code that imports a shared dependency is executing synchronously before Module Federation has initialized the shared scope.
Fix:
- Ensure the entry point is
import('./bootstrap')— nothing else (see Async Bootstrap Requirement). - Check that no shared dependency has
eager: truein a remote. Only the host may useeager: true, and only with careful consideration. - Verify that no top-level
importin the entry file pulls in React or other shared dependencies.
HMR Limitations with Remotes
Symptom: Changes to a remote's code during development do not hot-reload in the shell. The shell must be fully reloaded.
Cause: Module Federation's runtime caches loaded remote modules. HMR within a remote's own dev server works fine, but the shell's HMR system does not know how to invalidate federated modules.
MF 2.0 update: Module Federation 2.0 has significantly improved HMR support. Intra-remote HMR (changes within a single remote reflected in its own dev server) now works reliably out of the box. However, cross-remote HMR — where a change in one remote is hot-reloaded inside the shell that hosts multiple remotes — still requires a full page reload in some configurations (particularly when shared singleton dependencies are involved).
Workaround:
- During development, run each MFE independently on its own port and use
remoteEntry.js(not the manifest) for faster dev reloads. - Use the shell's dev server primarily for integration testing.
- Accept that a full page reload is needed when switching between shell and remote development.
pnpm Compatibility
Symptom: Shared dependencies fail to resolve. Errors like Module not found: Can't resolve 'react' in remotes, even though React is installed.
Cause: pnpm's strict node_modules structure (symlinked, non-flat) means that shared dependencies may not be hoisted to a location where Module Federation can find them.
Fix: Add one of the following to .npmrc at the workspace root:
# Option 1: Hoist everything (simplest, but loses pnpm's strictness benefits)
shamefully-hoist=true
# Option 2: Hoist only the packages that Module Federation needs to share (recommended)
public-hoist-pattern[]=react
public-hoist-pattern[]=react-dom
public-hoist-pattern[]=react-router-dom
public-hoist-pattern[]=zustand
public-hoist-pattern[]=@acme/design-system
Option 2 is recommended because it preserves pnpm's strictness for all other packages while ensuring Module Federation can resolve shared dependencies.
CORS Headers for Cross-Origin Remotes
Symptom: Access to script at 'https://cdn.example.com/mfe-dashboard/remoteEntry.js' from origin 'https://app.example.com' has been blocked by CORS policy
Cause: The shell and remotes are served from different origins. Browsers enforce CORS for dynamically loaded scripts when using import() or new Function().
Fix: The CDN or Cloudflare Worker serving remote assets must include CORS headers:
// In the Cloudflare Worker that serves MFE assets
const corsHeaders = {
'Access-Control-Allow-Origin': 'https://app.example.com',
'Access-Control-Allow-Methods': 'GET, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
};
// For development, allow all origins
if (env.ENVIRONMENT === 'development') {
corsHeaders['Access-Control-Allow-Origin'] = '*';
}
Important: Do not use Access-Control-Allow-Origin: * in production if your application uses credentials (cookies, auth headers). Specify the exact allowed origin.
Chunk Loading Failures in Production
Symptom: Users see blank screens or broken MFEs after a deployment. The browser console shows 404 errors for JS/CSS chunks.
Cause: A new version of an MFE was deployed, replacing chunks on the CDN, but users who had the page open still have the old manifest cached. The old manifest references chunk URLs that no longer exist.
Fix (multi-layered):
-
Content-hash all chunks: This is the default in Rsbuild/Rspack. Each chunk filename includes a hash of its contents. New deployments produce new filenames; old files remain on the CDN.
-
Never delete old chunks immediately: Retain old versions on R2/CDN for at least 24 hours (or longer) after deployment. The version config in KV points to the new version, but users mid-session may still need old chunks.
-
Manifest cache control: Set
Cache-Control: no-cacheonmf-manifest.jsonso browsers always revalidate. UseETagfor conditional requests. -
Runtime error recovery: The
RemoteComponentwrapper retries on chunk load failures. If retries fail, it shows an error state with a "Refresh page" button. -
Version-prefixed paths: Deploy each version to a unique path (
/mfe-dashboard/v1.2.3/). This guarantees chunk URL stability. The version config in KV maps MFE names to versioned paths.
# CDN directory structure
/mfe-dashboard/
v1.2.0/
mf-manifest.json
remoteEntry.js
static/js/expose_App.abc123.js
static/js/expose_App.abc123.js.map
v1.2.1/
mf-manifest.json
remoteEntry.js
static/js/expose_App.def456.js
static/js/expose_App.def456.js.map
References
- @module-federation/enhanced (npm package): https://www.npmjs.com/package/@module-federation/enhanced
- Module Federation v2 documentation: https://module-federation.io/
- Module Federation v2 guide — Getting Started: https://module-federation.io/guide/start/index.html
- Module Federation v2 guide — Shared Dependencies: https://module-federation.io/guide/basic/shared.html
- Module Federation v2 guide — Dynamic Remote Loading: https://module-federation.io/guide/basic/runtime.html
- Module Federation v2 guide — Type Hints (DTS): https://module-federation.io/guide/basic/type-prompt.html
- Rsbuild Module Federation plugin: https://rsbuild.dev/plugins/list/plugin-module-federation
- Rspack Module Federation: https://rspack.dev/guide/features/module-federation
- Chrome DevTools plugin for Module Federation: https://module-federation.io/guide/basic/chrome-devtool.html