← Back to Blog
React2026-05-13Β·79 min read

Frankenstein Meeting Room: Three Apps in One Browser Tab

By Lutz Leonhardt

Cross-Framework Micro-Frontends: Architecting Federated Applications with Native Federation

Current Situation Analysis

Enterprise frontend landscapes rarely evolve in a straight line. Over the past decade, teams have adopted Angular, React, Vue, and Svelte in response to shifting requirements, hiring patterns, and performance needs. The result is a fragmented codebase where multiple frameworks coexist, often serving different business domains. When leadership demands a unified user experience, the default reaction is a complete framework migration. This approach consistently underestimates three critical factors: regression surface area, team velocity loss, and the hidden cost of rewriting stable, working features.

Migration projects frequently stall because they treat framework unification as a binary switch rather than a gradual integration problem. Teams end up maintaining two codebases simultaneously, burning through budgets while delivering minimal user value. The industry has largely overlooked a more pragmatic path: runtime orchestration. Instead of forcing a single framework, modern federation tools allow heterogeneous applications to share modules, coordinate state, and render within a single browser context without build-time coupling.

Native Federation v4 has emerged as a production-ready solution for this exact scenario. It shifts the integration burden from runtime JavaScript bridges to compile-time module resolution, enabling teams to incrementally adopt new frameworks while preserving existing investments. The approach is not about replacing legacy code; it is about creating a controlled boundary where isolated applications can communicate safely, share dependencies efficiently, and evolve independently.

WOW Moment: Key Findings

The most significant insight from federated integration is that complexity moves, it does not disappear. Traditional micro-frontend approaches push complexity into runtime message passing, iframe sandboxing, or custom routing layers. Federation moves that complexity into the build pipeline, where it becomes deterministic, version-controlled, and easier to audit.

Approach Integration Time Runtime Overhead State Sync Complexity Framework Lock-in
Full Rewrite 6–18 months Minimal Low (single codebase) High (forced migration)
iframe Isolation 2–4 weeks High (duplicate DOM/CSS) High (postMessage friction) Low (but UX suffers)
Federation Orchestration 4–8 weeks Low (shared dependencies) Medium (contract-driven) None (incremental adoption)

This finding matters because it changes how engineering leaders evaluate integration strategies. Federation enables teams to treat each framework as a deployable module rather than a monolithic commitment. You can upgrade a React panel to React 19 without touching the Angular shell, or replace a Svelte diagram component with a Vue alternative, all while maintaining a consistent user experience. The trade-off is a slightly more complex build configuration, but that complexity is isolated, testable, and pays dividends in long-term maintainability.

Core Solution

Building a federated multi-framework application requires a disciplined architecture. The goal is to establish a host shell that loads remote applications at runtime, while enforcing strict communication boundaries to prevent cross-framework contamination.

Step 1: Establish a Shared Contract Layer

Before configuring any framework, define the communication contract in a shared package. This package contains TypeScript interfaces, event payloads, and the event bridge implementation. By keeping this framework-agnostic, you ensure that Angular, React, and Svelte can all import the same types without coupling to each other's runtime.

// packages/shared/src/contract.ts
export type PayloadContract = {
  'session:activate': { sessionId: string; metadata: Record<string, unknown> };
  'canvas:update': { sessionId: string; payload: CanvasData };
  'diagram:render': { sessionId: string; source: string };
};

export type DeepReadonly<T> = T extends object
  ? { readonly [P in keyof T]: DeepReadonly<T[P]> }
  : T;

The DeepReadonly utility prevents accidental mutation across framework boundaries. When a React component passes an object to an Angular service, TypeScript will flag any attempt to modify nested properties, catching cross-boundary side effects at compile time rather than runtime.

Step 2: Implement the Cross-Framework Event Bridge

Instead of relying on framework-specific state management or iframe messaging, attach a singleton event bridge to globalThis. This ensures exactly one instance exists regardless of how many remotes load, and avoids the performance penalty of deep cloning large payloads.

// packages/shared/src/bridge.ts
import type { PayloadContract } from './contract';

const BRIDGE_KEY = '__app_federation_bridge__';

class CrossAppBridge {
  private target: EventTarget;

  constructor() {
    this.target = (globalThis as Record<string, unknown>)[BRIDGE_KEY] ??= new EventTarget();
  }

  dispatch<K extends keyof PayloadContract>(
    type: K,
    payload: DeepReadonly<PayloadContract[K]>
  ): void {
    const event = new CustomEvent(type, { detail: payload });
    this.target.dispatchEvent(event);
  }

  subscribe<K extends keyof PayloadContract>(
    type: K,
    handler: (detail: PayloadContract[K]) => void
  ): () => void {
    const listener = (e: Event) => handler((e as CustomEvent).detail);
    this.target.addEventListener(type, listener);
    return () => this.target.removeEventListener(type, listener);
  }
}

export const bridge = new CrossAppBridge();

This star-shaped topology forces all communication through the host. Remotes never talk to each other directly, which simplifies debugging, prevents circular dependencies, and makes it trivial to add logging or validation middleware later.

Step 3: Configure the Host Shell

The host application (typically Angular or React) acts as the orchestrator. It must load the federation manifest, inject the import map, and only then bootstrap the framework. This two-stage startup prevents race conditions where remotes attempt to resolve modules before the import map is available.

// apps/host/src/bootstrap.ts
async function initializeFederation(): Promise<void> {
  const manifest = await fetch('/remote-entry.json').then(r => r.json());
  const importMap = { imports: {} as Record<string, string> };

  for (const [key, url] of Object.entries(manifest)) {
    importMap.imports[key] = url as string;
  }

  const script = document.createElement('script');
  script.type = 'importmap';
  script.textContent = JSON.stringify(importMap);
  document.head.appendChild(script);

  await import('./main');
}

initializeFederation().catch(console.error);

The federation configuration defines which dependencies are shared. Libraries like rxjs, zustand, or svelte/internal are marked as singletons, ensuring only one instance loads even if multiple remotes declare them as dependencies. This dramatically reduces bundle size and prevents subtle bugs caused by duplicate class instances.

Step 4: Implement Remote Applications

Each remote application exports a custom element or module that the host can mount. The remote maintains its own build pipeline but includes a federation configuration that declares its exports and shared dependencies.

// apps/canvas-remote/src/federation.config.mjs
export default {
  name: 'canvas_remote',
  filename: 'remote-entry.json',
  exposes: {
    './Panel': './src/CanvasPanel.tsx',
  },
  shared: {
    react: { singleton: true, requiredVersion: '^18.0.0' },
    'react-dom': { singleton: true, requiredVersion: '^18.0.0' },
  },
};

The remote can run independently during development, but the federation build only needs the compiled JavaScript chunks and the remote-entry.json manifest. A running dev server is not required for production deployment, which simplifies CI/CD pipelines and reduces infrastructure costs.

Step 5: Orchestrate State and Persistence

The host manages the source of truth. When a user selects a session, the host broadcasts the activation event. Remotes listen, fetch their specific data, and render. Updates flow back through the bridge, but the host applies a stale-update guard to prevent race conditions during rapid navigation.

// apps/host/src/services/session-manager.ts
import { bridge } from '@app/shared/bridge';
import { signal } from '@angular/core';

export class SessionManager {
  private activeId = signal<string | null>(null);

  constructor() {
    bridge.subscribe('canvas:update', (payload) => {
      if (payload.sessionId !== this.activeId()) return;
      this.persist(payload);
    });
  }

  activate(id: string): void {
    this.activeId.set(id);
    bridge.dispatch('session:activate', { sessionId: id, metadata: {} });
  }

  private persist(payload: unknown): void {
    localStorage.setItem('app_state', JSON.stringify(payload));
  }
}

This pattern ensures that even if a remote sends an update after the user has navigated away, the host silently discards it. The guard is a single line, but it prevents silent data corruption that would otherwise manifest as intermittent UI glitches.

Pitfall Guide

1. Stale State Overwrites

Explanation: Remotes often debounce updates to reduce network or render overhead. If a user navigates away during the debounce window, the delayed payload overwrites the new session's data. Fix: Always validate the session identifier against the current active state before applying updates. Implement a version counter or timestamp in the payload to detect out-of-order delivery.

2. Cosmetic Event Spam

Explanation: Frameworks like React or Excalidraw fire change events on layout recalculations, window resizing, or focus shifts, not just actual data modifications. This floods the event bridge with unnecessary payloads. Fix: Generate a lightweight fingerprint of the payload before debouncing. Compare it against the previous fingerprint and skip dispatch if only cosmetic properties changed. This reduces bridge traffic by 60–80% in complex UIs.

3. Shared Dependency Duplication

Explanation: If a shared library is accidentally listed as a production dependency in the remote instead of a dev dependency, the federation build may bundle it twice. This causes duplicate class instances, breaking singletons and state managers. Fix: Mark framework-agnostic shared packages as devDependencies in remotes. Configure shareAll or explicit shared mappings to skip bundling them. Verify the final bundle with webpack-bundle-analyzer or rollup-plugin-visualizer.

4. Browser-Specific Canvas/Viewport Limits

Explanation: Firefox caps <canvas> elements at approximately 11180 pixels per edge. If a remote computes dimensions before stylesheets load, it may exceed this limit and crash silently. Fix: Inject critical stylesheets during module initialization, not component mount. Wait for the load event before rendering canvas-based components. Add a runtime dimension clamp as a defensive fallback.

5. CJS/ESM Interop Failures

Explanation: Some frameworks still ship CommonJS modules that federation attempts to convert to ESM. The react/jsx-runtime package is a known offender where the conversion leaves the jsx function undefined at runtime. Fix: Use path mapping in the federation configuration to bypass problematic intermediate modules. Pin exact versions of runtime dependencies and test federation builds in CI with actual remote resolution, not just local compilation.

6. Build Process Hangs

Explanation: Federation dev servers often run in watch mode and do not exit cleanly when triggered by automated scripts. CI pipelines or agentic workflows will hang indefinitely waiting for a process that never terminates. Fix: Wrap the build command in a script that polls for the output artifact, then sends SIGKILL to the process group. Alternatively, use the --watch=false flag if supported, or run builds in isolated containers with timeout limits.

7. Cross-Boundary Mutation

Explanation: JavaScript passes objects by reference. If a remote mutates a payload object after dispatching it, the host or other remotes may see unexpected changes, especially in frameworks with reactive change detection. Fix: Enforce DeepReadonly at the type level. At runtime, serialize and deserialize payloads before dispatch if mutation is a concern, or use immutable data structures in the shared contract.

Production Bundle

Action Checklist

  • Define a strict payload contract with DeepReadonly types in a shared package
  • Implement a globalThis singleton event bridge with typed dispatch/subscribe methods
  • Configure the host to load the federation manifest and inject the import map before bootstrap
  • Mark shared framework packages as devDependencies in remotes to prevent duplicate bundling
  • Add stale-update guards in the host service to discard out-of-order payloads
  • Implement payload fingerprinting to filter cosmetic change events before debouncing
  • Verify browser-specific limits (canvas, viewport, memory) and add defensive clamps
  • Run federation builds in CI with actual remote resolution to catch CJS/ESM interop issues

Decision Matrix

Scenario Recommended Approach Why Cost Impact
Legacy app with 3+ frameworks Federation Orchestration Avoids rewrite risk, enables incremental upgrades Low initial, high long-term ROI
Isolated admin panel iframe or Web Component Simpler isolation, no shared state needed Low
Performance-critical dashboard Single Framework Rewrite Eliminates federation overhead, optimizes bundle High upfront, low maintenance
Rapid prototyping Federation with mock remotes Fast iteration, framework-agnostic contracts Medium
Strict security/compliance Federation with strict CSP Controlled module loading, audit trail Medium

Configuration Template

// federation.config.mjs (Host)
export default {
  name: 'host_shell',
  remotes: {
    canvas_remote: 'http://localhost:4201/remote-entry.json',
    diagram_remote: 'http://localhost:4202/remote-entry.json',
  },
  shared: {
    rxjs: { singleton: true, requiredVersion: '^7.0.0' },
    '@app/shared': { singleton: true },
  },
};

// federation.config.mjs (Remote)
export default {
  name: 'canvas_remote',
  filename: 'remote-entry.json',
  exposes: {
    './Panel': './src/CanvasPanel.tsx',
  },
  shared: {
    react: { singleton: true, requiredVersion: '^18.0.0' },
    'react-dom': { singleton: true, requiredVersion: '^18.0.0' },
  },
};

Quick Start Guide

  1. Initialize a pnpm monorepo with apps/host, apps/canvas-remote, and packages/shared.
  2. Install @module-federation/enhanced (or Native Federation v4 equivalent) in all workspaces.
  3. Create the shared payload contract and event bridge in packages/shared, then publish locally via workspace protocol.
  4. Configure the host to inject the import map before framework bootstrap, and set up remote configs with explicit shared dependencies.
  5. Run pnpm dev in each workspace, verify remote resolution in the browser network tab, and validate cross-framework communication through the event bridge.