Module Federation v2

Table of Contents


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.

Featurev1 (webpack 5 native)v1.5 (community / @module-federation/enhanced)v2 (Rspack-native, official successor)
Bundlerwebpack 5 onlywebpack 5 (plugin-based)Rspack / Rsbuild (first-class), webpack 5 supported
Remote EntryStatic remoteEntry.js with fixed URL at build timeStatic or dynamic; manifest-based discoveryDynamic via mf-manifest.json; static fallback supported
Runtime APINone; purely declarative via plugin config@module-federation/enhanced/runtimeinit(), loadRemote(), registerRemotes()Same runtime API, stabilized and extended
Shared Dependency NegotiationWebpack internal scope-based resolutionRuntime-negotiated via manifest metadataRuntime-negotiated; improved version range matching
Type HintsNoneExperimental dts plugin; types generated at build timeStable dts plugin; types served alongside manifest and auto-fetched by host
DevToolsNoneBasic loggingChrome DevTools plugin for inspecting federation graph, shared deps, loaded remotes
Manifest ProtocolN/A — only remoteEntry.jsmf-manifest.json introducedmf-manifest.json standardized; includes exposed modules, shared deps, type URLs
Dynamic Remote RegistrationNot supported nativelyregisterRemotes() at runtimeregisterRemotes() with hot-update support
Build Performancewebpack 5 speedswebpack 5 speedsRspack (Rust-based) — 5-10x faster builds
HMR for RemotesNot supportedPartial; often requires full reloadSignificantly improved in 2.0; intra-remote HMR works reliably, cross-remote HMR still requires full page reload in some configurations
Monorepo SupportManual configurationImproved plugin defaultsFirst-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/enhanced used 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.

Module Federation v2 Runtime Loading Flow MF v2 Runtime Loading Flow Host App(Shell) mf-manifest.jsonVersion config CDN / R2Chunk download Shared DepsNegotiate / skip Mount 1. init() Initialize MF runtime with shared config 2. registerRemotes() Dynamic remote URLs from version config 3. loadRemote() Fetch manifest, resolve shared deps, load chunks 4. Component mounts Inside <Suspense> + <ErrorBoundary> in Shell's React tree

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:

  • remotes is 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: false on 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.consumeTypes tells the build to pull .d.ts tarballs 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 the mf-manifest.json to 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.
  • exposes uses the "./ModuleName" convention. The host loads these as loadRemote("mfe_dashboard/App"). The leading ./ in the key is required by the plugin but stripped in the runtime API call.
  • dts.generateTypes produces a @mf-types.d.ts.tgz (or individual .d.ts files, depending on configuration) that the host's dts.consumeTypes pulls at build time.
  • The name field must be a valid JavaScript identifier (no hyphens). Use underscores: mfe_dashboard, not mfe-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.

Shared Dependency Negotiation — Host declares shared, Remote checks, singleton or separate Shared Dependency Negotiation Host (Shell)Declares shared depsreact: ^19.2.4 manifest Remote (MFE)Checks version rangereact: ^19.2.4 Match? Use Host SingletonOne shared instance Load SeparateDuplicate instance ⚠ singleton: trueOnly one version loadedRequired for React hooks eager: falseLoaded async for properbootstrap negotiation requiredVersionSemver range checked atruntime by MF negotiation

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 requiredVersion ranges, 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 against zustand@^4.5.0 running with zustand@5.0.11 loaded by another remote). Recommendation: enforce identical requiredVersion ranges across all remotes and the host. Add a CI lint step that compares shared configs across all rsbuild.config.ts files 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

StrategyBehaviorUse Case
eager: false (default)Shared dependency is loaded asynchronously when first neededStandard for all remotes and the host
eager: trueShared dependency is bundled into the initial chunk and loaded synchronouslyOnly 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:

  1. Singleton mode: The runtime uses the shell's 19.1.0 because 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).
  2. 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, not 19.2.4.
  • Run a CI check that compares requiredVersion ranges across all rsbuild.config.ts files 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.

Dynamic Remote Registration Flow — fetch config, registerRemotes, loadRemote Dynamic Remote Registration Flow Fetch ConfigVersion Config KV registerRemotes()Map name → URL loadRemote()On route navigation CDN FetchChunks + manifest Render Config: { "mfe_a": "https://cdn/…/mf-manifest.json" }Fetched once at Shell bootstrap Remotes registered dynamicallyNo hardcoded URLs in build config Lazy loaded per routeSuspense boundary shows loading

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.

Type Generation Across Repos — build, @mf-types, consume TypeScript Type Generation Across Repos Remote MFE Build dts.generateTypes = true Extracts .d.ts for exposes Bundles into @mf-types.zip deploy CDN / R2 mf-manifest.json @mf-types.zip JS chunks fetch Host Build (Shell) dts.consumeTypes = true Downloads @mf-types.zip → Full IntelliSense + type checking

How It Works

  1. At build time, the remote MFE's dts.generateTypes configuration extracts TypeScript declarations for all exposes entries.
  2. The generated .d.ts files are bundled into a tarball (e.g., @mf-types.zip) and placed alongside the mf-manifest.json in the build output.
  3. At the host's build time, the dts.consumeTypes configuration downloads these tarballs from each remote's manifest URL and extracts them into a local @mf-types/ directory.
  4. 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:

  1. CDN-served types (recommended): The generated type tarball is deployed alongside the manifest. The host's consumeTypes fetches it over HTTPS.
  2. npm package: Each MFE publishes a @acme/mfe-dashboard-types package. This couples deployment to npm publish cycles and is not recommended.
  3. 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

  1. Discovery: When init() or registerRemotes() is called with a manifest URL, the runtime fetches the JSON.
  2. Shared dependency negotiation: The runtime compares the shared entries across all loaded manifests and the host. It determines which version of each shared dependency to use, respecting singleton, requiredVersion, and version precedence rules.
  3. Module loading: When loadRemote("mfe_dashboard/App") is called, the runtime looks up the exposes entry for ./App, resolves the asset URLs relative to the manifest URL, and dynamically loads the JavaScript chunk.
  4. Type resolution: The host build reads metaData.types to know where to fetch the .d.ts tarball.

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-cache with ETag validation, because the host fetches it on every page load to discover the current chunk URLs.

Warning — manifest freshness race condition: The mf-manifest.json can 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> or mf-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:

  1. Rspack evaluates the entry module synchronously.
  2. The entry module imports React (a shared dependency).
  3. Module Federation has not yet had a chance to check which version of React to use (host's or remote's).
  4. 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.

Error Boundary and Fallback Hierarchy — Suspense, ErrorBoundary, Fallback UI Error Boundary & Fallback Hierarchy <React.Suspense> fallback = Loading spinner <ErrorBoundary> fallback = Error message + Retry button Remote Component ✓ Loading…While chunks download Error + RetryNetwork / runtime error Load-time Error404, parse error, CDN down

Layered Error Handling

The RemoteComponent wrapper shown in Dynamic Remote Loading implements three layers:

  1. 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.

  2. 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.

  3. Suspense boundary: If the remote component uses React.lazy or 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:

  1. Ensure the entry point is import('./bootstrap') — nothing else (see Async Bootstrap Requirement).
  2. Check that no shared dependency has eager: true in a remote. Only the host may use eager: true, and only with careful consideration.
  3. Verify that no top-level import in 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):

  1. 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.

  2. 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.

  3. Manifest cache control: Set Cache-Control: no-cache on mf-manifest.json so browsers always revalidate. Use ETag for conditional requests.

  4. Runtime error recovery: The RemoteComponent wrapper retries on chunk load failures. If retries fail, it shows an error state with a "Refresh page" button.

  5. 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