All writing
12 min read

Module Federation in Practice: Micro-Frontends Without the Hype

Micro-frontends are an organizational tool, not a default architecture. When Module Federation genuinely helps, how it works, and the failure modes to plan for.

ArchitectureMicro-FrontendsModule Federation

Micro-frontends get pitched as a frontend silver bullet and adopted by teams who would be better served by a well-structured monolith. So let me start with the uncomfortable part: most apps do not need them. They are an answer to an organizational problem — multiple teams needing to deploy parts of one app independently — not a technical upgrade. If you have one team, you almost certainly do not want them.

The problem they actually solve

At a certain scale, a single deployable frontend becomes a coordination bottleneck. Team A cannot ship until Team B's half-finished work is feature-flagged off; a shared release train means everyone moves at the speed of the slowest reviewer. Micro-frontends let each team own, build, and deploy a slice of the application on its own cadence. That independence is the entire value proposition — and the entire source of the complexity.

How Module Federation works

Webpack Module Federation (and its Vite equivalents) lets one build expose modules that another build loads at runtime, over the network, rather than at compile time. A 'host' application consumes modules exposed by 'remotes'. The remote builds and deploys independently; the host pulls in its latest exposed entry at runtime.

js
// remote (the team that owns the cart) — webpack.config.js
new ModuleFederationPlugin({
  name: "cart",
  filename: "remoteEntry.js",
  exposes: {
    "./CartWidget": "./src/CartWidget",
  },
  shared: {
    react: { singleton: true, requiredVersion: "^18.0.0" },
    "react-dom": { singleton: true, requiredVersion: "^18.0.0" },
  },
});
js
// host (the shell app) — webpack.config.js
new ModuleFederationPlugin({
  name: "shell",
  remotes: {
    cart: "cart@https://cart.example.com/remoteEntry.js",
  },
  shared: {
    react: { singleton: true, requiredVersion: "^18.0.0" },
    "react-dom": { singleton: true, requiredVersion: "^18.0.0" },
  },
});

The host then imports the remote module as if it were local, lazily — because it crosses a network boundary:

tsx
import { lazy, Suspense } from "react";

// Resolved at runtime from the remote's deployed bundle.
const CartWidget = lazy(() => import("cart/CartWidget"));

export function Header() {
  return (
    <Suspense fallback={<CartSkeleton />}>
      <CartWidget />
    </Suspense>
  );
}

The `singleton` flag is not optional

That shared config is the part teams get wrong and then spend a week debugging. React must be a singleton across the host and every remote — two copies of React in one page break hooks instantly, because hooks rely on a single shared internal dispatcher. Marking react and react-dom as singleton: true tells Module Federation to load exactly one copy and share it. Anything stateful that must be unique per page belongs here.

If you see 'Invalid hook call' or 'cannot read useState of null' in a federated app, your first suspect is two Reacts on the page. Check your shared singletons before anything else.

Version skew is the real tax

Independent deployment means the host and remotes are versioned separately and can drift. The host might load a remote built against a newer shared contract, or vice versa. Plan for it deliberately:

  • Define the shared contract — the props a remote exposes — as an explicit, versioned interface, and treat changes to it as breaking.
  • Set requiredVersion on shared dependencies so incompatible versions fail fast and loudly instead of corrupting state silently.
  • Wrap every remote in an error boundary; a remote can fail to load entirely, and that must degrade gracefully rather than blank the page.
tsx
class RemoteBoundary extends React.Component<
  { fallback: React.ReactNode; children: React.ReactNode },
  { failed: boolean }
> {
  state = { failed: false };
  static getDerivedStateFromError() {
    return { failed: true };
  }
  render() {
    return this.state.failed ? this.props.fallback : this.props.children;
  }
}

Operational realities to budget for

Beyond the build config, federation changes how you operate. You now have multiple deploy pipelines, multiple things that can be down independently, and a harder debugging story because a bug might live in a remote you do not own. Observability has to span boundaries — a single trace should follow a request through host and remotes alike, or production incidents become guesswork.

So should you use it?

  1. Do you have multiple teams that are genuinely blocked by a shared release cycle? If no, stop — use a monorepo with good module boundaries instead.
  2. Can you commit to governing shared dependencies and a versioned contract across teams? If no, the version skew will hurt.
  3. Do you have the observability and error-handling maturity to operate several independently-deployed surfaces? If no, build that first.

Module Federation is a genuinely impressive piece of engineering, and when the organizational problem is real it is the right tool. Just adopt it for the autonomy it buys your teams — not because the architecture diagram looks modern. The runtime complexity is real, and you pay it every day you run the system.