arrow_back Journal / Architecture

Architecting Scalable Micro-frontends with React & Module Federation

A deep dive into orchestration patterns for enterprise-grade distributed frontends, balancing team autonomy with performance optimization.

Ioannis Karasavvaidis | 12 min read October 15, 2024
React Module Federation Micro-frontends TypeScript

The Problem with Monolithic Frontends

As engineering organizations scale beyond 5-6 frontend teams, the monolithic single-page application becomes a bottleneck. Merge conflicts multiply, deployment cycles slow to a crawl, and a single broken import can take down the entire application.

Module Federation, introduced in Webpack 5, offers an elegant solution: runtime code sharing between independently deployed applications. But the devil is in the orchestration.

The Shell Architecture

The key insight is treating the host application as a thin shell — responsible only for routing, authentication, and layout chrome. Each micro-frontend owns its vertical slice:

// shell/webpack.config.ts
const ModuleFederationPlugin = require("webpack/lib/container/ModuleFederationPlugin");

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

The singleton: true flag is critical — without it, you’ll end up with multiple React instances and the infamous “hooks can only be called inside a function component” error.

Shared State Without Coupling

The biggest architectural trap is sharing state between micro-frontends through a global store. This creates invisible coupling that defeats the purpose of independent deployment.

Instead, use an event bus pattern with typed contracts:

// shared/events.ts
interface MFEvent<T = unknown> {
  type: string;
  payload: T;
  source: string;
  timestamp: number;
}

class MicroFrontendBus {
  private handlers = new Map<string, Set<(event: MFEvent) => void>>();

  emit<T>(type: string, payload: T, source: string) {
    const event: MFEvent<T> = {
      type,
      payload,
      source,
      timestamp: Date.now(),
    };
    this.handlers.get(type)?.forEach((handler) => handler(event));
  }

  on(type: string, handler: (event: MFEvent) => void) {
    if (!this.handlers.has(type)) {
      this.handlers.set(type, new Set());
    }
    this.handlers.get(type)!.add(handler);
    return () => this.handlers.get(type)?.delete(handler);
  }
}

export const bus = new MicroFrontendBus();

Each micro-frontend publishes and subscribes to well-defined events. The contract is the event type — not the implementation.

Performance: The Hidden Cost

Module Federation introduces runtime overhead: remote entry files must be fetched, parsed, and executed before the micro-frontend renders. On a fast connection, this adds 200-400ms. On 3G, it can exceed 2 seconds.

Three strategies to mitigate this:

  1. Prefetch remote entries on hover or route proximity
  2. Server-side composition for above-the-fold content
  3. Shared chunk deduplication via the shared configuration

The third point is particularly important. Without careful shared dependency configuration, you’ll ship React three times to the browser.

Deployment Independence

The entire value proposition collapses if you can’t deploy independently. Each micro-frontend needs:

  • Its own CI/CD pipeline
  • Semantic versioning for breaking changes
  • A health check endpoint the shell can query
  • Graceful degradation when unavailable
// shell/RemoteLoader.tsx
function RemoteLoader({ remote, fallback }: Props) {
  const [Component, setComponent] = useState<React.ComponentType | null>(null);
  const [error, setError] = useState(false);

  useEffect(() => {
    loadRemote(remote)
      .then((mod) => setComponent(() => mod.default))
      .catch(() => setError(true));
  }, [remote]);

  if (error) return fallback;
  if (!Component) return <Skeleton />;
  return <Component />;
}

When Not to Use Micro-frontends

Micro-frontends are organizational architecture, not technical architecture. If you have fewer than 4 frontend teams, the coordination overhead exceeds the benefits. A well-structured monorepo with code ownership rules (CODEOWNERS) and independent deployment pipelines gives you 80% of the benefits at 20% of the complexity.

The question isn’t “can we build micro-frontends?” — it’s “do we have the team topology that demands them?”