Skip to content

Module Federation in Practice

expert17 min read

What Module Federation Actually Does

Module Federation lets separately built and deployed JavaScript applications share code at runtime. No npm publishing. No build-time integration. Application A loads a component from Application B's deployed bundle as if it were a local import.

This is the technology that makes client-side micro-frontends practical. Before Module Federation, sharing code between separately deployed apps required UMD scripts on a CDN, iframe embedding, or custom loaders. Module Federation makes it a webpack/rspack config option.

Mental Model

Think of a franchise restaurant chain. Each location (remote app) prepares its own menu items independently. The delivery service (Module Federation) picks up dishes from any location and delivers them to any customer (host app). The customer does not know or care which kitchen made the dish -- they just ordered it from the app. Each kitchen can update its recipes independently without coordinating with other locations.

Core Concepts

Host: The application that consumes remote modules. This is typically the shell app that provides routing and layout.

Remote: An application that exposes modules for other applications to consume. Each micro-frontend is a remote.

Shared: Dependencies that should be loaded once and shared across all host and remote applications. React is the most common shared dependency.

Container: The runtime manifest that tells the host where to find a remote's exposed modules and what shared dependencies it needs.

Host/Remote Setup

Remote Configuration (Checkout App)

const { ModuleFederationPlugin } = require("webpack").container;

module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: "checkout",
      filename: "remoteEntry.js",
      exposes: {
        "./CheckoutFlow": "./src/components/CheckoutFlow",
        "./CartSummary": "./src/components/CartSummary",
      },
      shared: {
        react: { singleton: true, requiredVersion: "^19.0.0" },
        "react-dom": { singleton: true, requiredVersion: "^19.0.0" },
      },
    }),
  ],
};

exposes declares which modules the checkout app makes available. filename: "remoteEntry.js" is the manifest file the host will load.

Host Configuration (Shell App)

const { ModuleFederationPlugin } = require("webpack").container;

module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: "shell",
      remotes: {
        checkout: "checkout@https://checkout.example.com/remoteEntry.js",
        dashboard: "dashboard@https://dashboard.example.com/remoteEntry.js",
      },
      shared: {
        react: { singleton: true, requiredVersion: "^19.0.0" },
        "react-dom": { singleton: true, requiredVersion: "^19.0.0" },
      },
    }),
  ],
};

Now the host can import from remotes as if they were local:

import { lazy, Suspense } from "react";

const CheckoutFlow = lazy(() => import("checkout/CheckoutFlow"));
const CartSummary = lazy(() => import("checkout/CartSummary"));

function CheckoutPage() {
  return (
    <Suspense fallback={<CheckoutSkeleton />}>
      <CartSummary />
      <CheckoutFlow />
    </Suspense>
  );
}

At runtime, import("checkout/CheckoutFlow") fetches the remote entry from the checkout app's deployed URL, resolves shared dependencies, and loads the component.

Quiz
What happens when the host loads a remote module and both have React as a shared singleton?

Shared Dependency Negotiation

This is where Module Federation gets sophisticated. When the host and a remote both declare React as shared, Module Federation performs version negotiation:

  1. Same version range → share a single copy
  2. Compatible version ranges (e.g., ^19.0.0 and ^19.1.0) → use the higher version
  3. Incompatible versions → load both (unless singleton: true, which forces one copy and warns)
shared: {
  react: {
    singleton: true,
    requiredVersion: "^19.0.0",
    strictVersion: false,
    eager: false,
  },
  "react-dom": {
    singleton: true,
    requiredVersion: "^19.0.0",
  },
  "@tanstack/react-query": {
    singleton: true,
    requiredVersion: "^5.0.0",
  },
}
eager loading pitfall

Setting eager: true on shared modules bundles them into the entry chunk instead of loading them asynchronously. This breaks the sharing mechanism because the eager copy loads before Module Federation can negotiate. Only use eager: true for the shell app's bootstrap file, never for shared dependencies.

The Bootstrap Pattern

Module Federation requires an async boundary before shared modules are used. The standard pattern:

// index.ts (entry point)
import("./bootstrap");
// bootstrap.tsx (actual app)
import { createRoot } from "react-dom/client";
import { App } from "./App";

createRoot(document.getElementById("root")!).render(<App />);

The dynamic import("./bootstrap") creates the async boundary that Module Federation needs to negotiate shared dependencies before the app renders.

Quiz
Why does Module Federation require the async bootstrap pattern?

Dynamic Remote Loading

Static remotes are defined in webpack config. But what if you want to load remotes dynamically -- for example, feature flags that determine which micro-frontends are active?

async function loadRemoteModule(
  scope: string,
  module: string,
  url: string
): Promise<{ default: React.ComponentType }> {
  await loadRemoteEntry(url);

  const container = (window as Record<string, unknown>)[scope] as {
    init: (shareScope: unknown) => Promise<void>;
    get: (module: string) => Promise<() => { default: React.ComponentType }>;
  };

  await container.init(__webpack_share_scopes__.default);
  const factory = await container.get(module);
  return factory();
}

async function loadRemoteEntry(url: string): Promise<void> {
  return new Promise((resolve, reject) => {
    const script = document.createElement("script");
    script.src = url;
    script.onload = () => resolve();
    script.onerror = () => reject(new Error(`Failed to load remote: ${url}`));
    document.head.appendChild(script);
  });
}

Usage:

function DynamicMicroFrontend({ name, url, module }: MicroFrontendConfig) {
  const [Component, setComponent] = useState<React.ComponentType | null>(null);
  const [error, setError] = useState<Error | null>(null);

  useEffect(() => {
    loadRemoteModule(name, module, url)
      .then((mod) => setComponent(() => mod.default))
      .catch(setError);
  }, [name, url, module]);

  if (error) return <MicroFrontendError name={name} error={error} />;
  if (!Component) return <MicroFrontendSkeleton />;

  return <Component />;
}

This enables scenarios like:

  • A/B testing different micro-frontend versions
  • Feature flags that enable/disable micro-frontends
  • Multi-tenant apps where each tenant gets different micro-frontends

Module Federation 2.0

Module Federation 2.0 (available via @module-federation/enhanced) introduces a runtime API that works with any bundler (webpack, rspack, Vite) and adds capabilities beyond the original webpack plugin.

Key improvements:

  • Runtime API: Load and manage remotes programmatically, not just via webpack config
  • Type generation: Automatic TypeScript type generation for remote modules
  • Manifest protocol: A standardized manifest format replacing remoteEntry.js
  • Preloading: Prefetch remote modules before they are needed
  • Snapshot: Version pinning for predictable deployments
import { init, loadRemote } from "@module-federation/enhanced/runtime";

init({
  name: "shell",
  remotes: [
    {
      name: "checkout",
      entry: "https://checkout.example.com/mf-manifest.json",
    },
    {
      name: "dashboard",
      entry: "https://dashboard.example.com/mf-manifest.json",
    },
  ],
  shared: {
    react: { version: "19.0.0", scope: "default", lib: () => React, shareConfig: { singleton: true } },
    "react-dom": { version: "19.0.0", scope: "default", lib: () => ReactDOM, shareConfig: { singleton: true } },
  },
});

const CheckoutFlow = lazy(() =>
  loadRemote("checkout/CheckoutFlow") as Promise<{ default: React.ComponentType }>
);
Common Trap

Module Federation 2.0 type generation creates .d.ts files for remote modules, giving you autocomplete and type checking across micro-frontend boundaries. But these types are generated at build time of the remote and may become stale if the remote deploys a breaking change. Treat generated types as a development aid, not a runtime guarantee. Always handle the case where a remote module's interface does not match expectations.

State Sharing Between Federated Modules

Federated modules need to share some state (authenticated user, theme, locale) without tight coupling.

Shared Context via Host

The host provides shared contexts. Remotes consume them:

// Host: provides the shared context
function App() {
  return (
    <AuthProvider>
      <ThemeProvider>
        <Router>
          <Suspense fallback={<ShellSkeleton />}>
            <Routes />
          </Suspense>
        </Router>
      </ThemeProvider>
    </AuthProvider>
  );
}

// Remote: consumes the context
function CheckoutFlow() {
  const { user } = useAuth();
  const { theme } = useTheme();

  return <div data-theme={theme}>Welcome, {user.name}</div>;
}

This works because the remote shares the same React instance as the host (via the singleton shared config). React Context traverses the component tree regardless of which bundle rendered the component.

Context only works with shared React

React Context relies on React's internal fiber tree. If the host and remote use different React instances (because sharing is not configured or versions are incompatible), Context will not propagate across the boundary. The remote will see null from useContext and throw an error.

Event-based State for Loose Coupling

When you cannot guarantee shared React instances (different frameworks, iframe isolation):

type SharedStateEvent = {
  type: "AUTH_CHANGED";
  payload: { user: User | null };
} | {
  type: "THEME_CHANGED";
  payload: { theme: "light" | "dark" };
} | {
  type: "LOCALE_CHANGED";
  payload: { locale: string };
};

function createEventBus() {
  const listeners = new Map<string, Set<(payload: unknown) => void>>();

  return {
    emit(event: SharedStateEvent) {
      const handlers = listeners.get(event.type);
      handlers?.forEach((handler) => handler(event.payload));
    },

    on<T extends SharedStateEvent["type"]>(
      type: T,
      handler: (payload: Extract<SharedStateEvent, { type: T }>["payload"]) => void
    ) {
      if (!listeners.has(type)) listeners.set(type, new Set());
      listeners.get(type)!.add(handler as (payload: unknown) => void);

      return () => listeners.get(type)?.delete(handler as (payload: unknown) => void);
    },
  };
}

Testing Strategies

Testing micro-frontends has three levels:

Execution Trace
Unit tests (per remote)
Test each micro-frontend in isolation. Mock shared dependencies. Standard React Testing Library.
90% of your tests live here
Integration tests (host + remote)
Run the host with one real remote and mock the others. Verify routing, shared state propagation, and error boundaries.
Test the composition layer
E2E tests (full system)
Deploy all micro-frontends to a staging environment. Playwright tests navigate through cross-micro-frontend flows.
Expensive but catches integration bugs

For integration testing, run remotes locally with a test configuration:

// test.config.ts
const testRemotes = {
  checkout: `checkout@http://localhost:3001/remoteEntry.js`,
  dashboard: `dashboard@http://localhost:3002/remoteEntry.js`,
};
Quiz
The checkout micro-frontend works perfectly in isolation but breaks when loaded in the host app. What is the most likely cause?
Key Rules
  1. 1Always use the async bootstrap pattern -- static imports before Module Federation negotiation break sharing
  2. 2Mark React and ReactDOM as singleton shared dependencies with compatible version ranges
  3. 3Use Module Federation 2.0 runtime API for dynamic remote loading and type generation
  4. 4Test at three levels: unit (each remote), integration (host + one remote), E2E (full system)
  5. 5Keep shared state minimal -- auth, theme, locale via host Context; feature state stays in remotes
What developers doWhat they should do
Setting eager: true on shared React to avoid the async bootstrap pattern
Eager loading bundles React into the entry chunk before Module Federation can negotiate versions. This means the first app to load forces its React version on everyone else, even if a remote has a more compatible version.
Using the async bootstrap pattern (dynamic import of bootstrap.tsx)
Each micro-frontend defines its own shared dependency versions without coordination
Uncoordinated shared versions cause unpredictable behavior. Module Federation's negotiation is deterministic but only if versions are compatible. A shared config ensures all remotes agree on acceptable version ranges.
A shared configuration package or convention that aligns shared dependency versions
No error boundaries around remote module loading
Remote modules load over the network and can fail (deploy in progress, CDN outage, version mismatch). Without error boundaries, one failed micro-frontend crashes the entire app.
Suspense + error boundaries wrapping every remote module mount point