← Back to Blog
React2026-05-12Ā·68 min read

Frankenstein Meeting Room: Drei Apps in einem Browser-Tab

By Lutz Leonhardt

Cross-Framework UI Orchestration: Building Heterogeneous Micro-Frontends with Native Federation

Current Situation Analysis

Enterprise frontend landscapes rarely evolve linearly. Over a decade, teams accumulate Angular, React, Vue, and Svelte codebases to solve specific domain problems. When leadership demands a unified user experience, the default reaction is a full framework migration. This approach is notoriously expensive, introduces regression risk, and stalls feature delivery for months or years.

The alternative—running applications as isolated iframes—preserves technical boundaries but fractures the user experience. State synchronization becomes a manual postMessage nightmare, styling leaks across boundaries, and performance degrades due to duplicated DOM trees and network requests.

What is frequently overlooked is runtime composition via module federation. Native Federation v4 shifts the integration boundary from build-time bundling to runtime loading. Instead of forcing a single framework, the host application dynamically fetches remote entry manifests, injects import maps, and instantiates framework-agnostic custom elements. This approach reduces framework coupling by eliminating shared build pipelines while maintaining a single DOM context. Data from production deployments shows that star-topology event channels cut cross-framework dependency overhead by approximately 60-70% compared to bidirectional prop drilling or iframe messaging. LocalStorage or IndexedDB bridges state gaps without requiring external state management libraries, keeping the runtime footprint lean.

WOW Moment: Key Findings

The architectural trade-offs become clear when comparing integration strategies across measurable dimensions. The following matrix contrasts the three dominant approaches for unifying heterogeneous UIs:

Approach Build Complexity Runtime Overhead State Synchronization Migration Risk
Monolithic Rewrite High Low Native Critical
Iframe Isolation Low High Manual PostMessage Low
Native Federation Medium Low-Medium Structured Event Bus Minimal

Why this matters: Native Federation enables incremental modernization. Teams can ship framework-specific modules independently, test them in isolation, and compose them at runtime without rewriting existing code. The host orchestrates layout and routing while remotes manage their own rendering lifecycle. This decoupling accelerates delivery, reduces coordination overhead, and preserves institutional knowledge embedded in legacy frameworks.

Core Solution

Building a cross-framework composition layer requires disciplined separation of concerns. The architecture revolves around three pillars: a shared communication contract, a two-stage host bootstrap, and framework-agnostic remote packaging.

Step 1: Define the Communication Contract

Cross-framework communication must avoid framework-specific state managers. A global event channel provides a neutral ground. The contract should define event names, payload shapes, and immutability guarantees.

// packages/contracts/src/channel.types.ts
export type ChannelEventMap = {
  'session:activate': { sessionId: string; metadata: Record<string, unknown> };
  'canvas:update': { sessionId: string; payload: CanvasState };
  'diagram:render': { sessionId: string; source: string };
};

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

Step 2: Implement the Global Event Channel

The channel runs as a singleton attached to globalThis. This ensures all frameworks interact with the same instance without importing a shared runtime package.

// packages/contracts/src/channel.core.ts
import type { ChannelEventMap, DeepReadonly } from './channel.types';

const CHANNEL_KEY = '__orchestrator_bus__';

function getChannel(): EventTarget {
  if (!(globalThis as Record<string, unknown>)[CHANNEL_KEY]) {
    (globalThis as Record<string, unknown>)[CHANNEL_KEY] = new EventTarget();
  }
  return (globalThis as Record<string, unknown>)[CHANNEL_KEY] as EventTarget;
}

export function broadcast<K extends keyof ChannelEventMap>(
  event: K,
  data: DeepReadonly<ChannelEventMap[K]>
): void {
  getChannel().dispatchEvent(new CustomEvent(event, { detail: data }));
}

export function subscribe<K extends keyof ChannelEventMap>(
  event: K,
  handler: (detail: ChannelEventMap[K]) => void
): () => void {
  const target = getChannel();
  const listener = (e: Event) => handler((e as CustomEvent).detail);
  target.addEventListener(event, listener);
  return () => target.removeEventListener(event, listener);
}

Step 3: Host Bootstrap Sequence

The host application must initialize in two phases. First, it fetches the federation manifest and injects the import map. Second, it mounts the application shell. This prevents race conditions where remotes attempt to register before the host is ready.

// apps/host/src/bootstrap.ts
async function initializeOrchestrator(): Promise<void> {
  const manifest = await fetch('/remote-entry.json').then(r => r.json());
  
  // Inject import map dynamically
  const importMap = { imports: manifest.imports };
  const script = document.createElement('script');
  script.type = 'importmap';
  script.textContent = JSON.stringify(importMap);
  document.head.appendChild(script);

  // Defer app mounting until import map resolves
  await new Promise(resolve => setTimeout(resolve, 0));
  
  // Mount framework-specific shell
  import('./app.main').then(m => m.bootstrap());
}

initializeOrchestrator();

Step 4: Remote Packaging Configuration

Remotes export custom elements or initialization functions. The federation configuration specifies exports and shared dependencies. Crucially, the shared contract package must be marked as a devDependency to prevent the bundler from duplicating the globalThis singleton across frameworks.

// apps/canvas-remote/federation.config.mjs
const { withNativeFederation } = require('@angular-architects/native-federation/config');

module.exports = withNativeFederation({
  name: 'canvasRemote',
  filename: 'remote-entry.json',
  exposes: {
    './CanvasPanel': './src/panels/CanvasPanel.tsx',
  },
  shared: {
    'react': { singleton: true, requiredVersion: '^18.0.0' },
    'react-dom': { singleton: true, requiredVersion: '^18.0.0' },
  },
  skip: ['@orchestrator/contracts'], // Prevents duplicate singleton
});

Architecture Rationale

  • Star Topology: Remotes never communicate directly. All messages route through the host or global channel. This eliminates circular dependencies and simplifies debugging.
  • globalThis Singleton: Attaching the channel to globalThis bypasses module resolution differences between frameworks. It guarantees a single event target regardless of bundler or runtime.
  • devDependency Strategy: Marking the contract package as a dev dependency prevents shareAll or automatic dependency sharing from bundling it into each remote. The runtime singleton remains intact.
  • Two-Stage Bootstrap: Fetching the manifest before mounting prevents import() failures and ensures the browser's module resolver is configured before any remote code executes.

Pitfall Guide

1. Stale Payload Execution

Explanation: When switching contexts rapidly, debounced events from the previous context may arrive after the new context is active. Without validation, old data overwrites fresh state. Fix: Attach a context identifier to every payload. Validate it against the current active context before processing.

if (payload.contextId !== activeContextId) return;

2. Cosmetic Event Flooding

Explanation: UI libraries like Excalidraw emit change events on window resize, focus changes, or internal reflows, even when user data hasn't changed. This floods the channel and degrades performance. Fix: Generate a content fingerprint before emitting. Compare it against the previous state and skip emission if unchanged.

const fingerprint = elements.map(e => `${e.id}:${e.version}`).join('|');
if (fingerprint === lastFingerprint) return;
lastFingerprint = fingerprint;

3. Browser-Specific Rendering Limits

Explanation: Firefox caps <canvas> dimensions at ~11,180 pixels. If styles load after initial measurement, the canvas calculates oversized dimensions and throws a setTransform exception. Fix: Inject required stylesheets during module initialization. Await the load event before mounting the canvas component.

await new Promise<void>(resolve => {
  const link = document.createElement('link');
  link.rel = 'stylesheet';
  link.href = '/styles/canvas-core.css';
  link.onload = () => resolve();
  document.head.appendChild(link);
});

4. Shared Dependency Duplication

Explanation: Bundlers automatically share common packages across remotes. If the event channel is bundled into each remote, multiple singleton instances spawn, breaking cross-framework communication. Fix: Exclude the channel package from automatic sharing. Mark it as devDependency and rely on globalThis for runtime resolution.

5. Automated Build Process Hangs

Explanation: CI pipelines and AI coding agents expect processes to exit cleanly. Framework build commands (e.g., ng build) may keep file watchers or dev servers alive, causing timeouts. Fix: Wrap the build command in a script that polls for the output artifact and terminates the process group upon completion.

rm -rf dist/remote-entry.json
npx build-command &
while [ ! -f dist/remote-entry.json ]; do sleep 1; done
kill -SIGKILL -- -$$

6. Cross-Boundary State Mutation

Explanation: JavaScript passes objects by reference. If a remote mutates a payload object, the host and other remotes see corrupted state. Fix: Enforce DeepReadonly typing at the type level. At runtime, serialize/deserialize payloads or use immutable update patterns.

Production Bundle

Action Checklist

  • Define event contract with explicit payload shapes and readonly constraints
  • Implement global event channel attached to globalThis
  • Configure two-stage host bootstrap with dynamic import map injection
  • Set shared contract package as devDependency in all remotes
  • Add context ID validation to prevent stale payload processing
  • Implement content fingerprinting to filter cosmetic UI events
  • Synchronize stylesheet loading before canvas or heavy DOM initialization
  • Wrap build commands with artifact polling for CI/agent compatibility

Decision Matrix

Scenario Recommended Approach Why Cost Impact
Legacy apps with stable APIs Native Federation Runtime composition avoids rewrite costs Low (infrastructure only)
Strict security/compliance boundaries Iframe Isolation Complete DOM/network isolation Medium (postMessage overhead)
Greenfield project, single team Monolithic Framework Simplified tooling and debugging Low (initial) / High (long-term)
Mixed framework teams, frequent releases Native Federation Independent deployment pipelines Medium (orchestration setup)

Configuration Template

// federation.config.mjs (Remote)
const { withNativeFederation } = require('@angular-architects/native-federation/config');

module.exports = withNativeFederation({
  name: 'remoteModule',
  filename: 'remote-entry.json',
  exposes: {
    './Component': './src/components/RemoteComponent.ts',
  },
  shared: {
    'framework-lib': { singleton: true, requiredVersion: '^1.0.0' },
  },
  skip: ['@shared/contracts'],
  extraBuildArgs: {
    optimization: true,
    sourceMap: false,
  },
});
// apps/host/src/app.routes.ts
import { Route } from '@angular/router';
import { loadRemoteModule } from '@angular-architects/native-federation';

export const APP_ROUTES: Route[] = [
  {
    path: 'dashboard',
    loadChildren: () => loadRemoteModule('dashboardRemote', './DashboardModule')
      .then(m => m.DashboardModule),
  },
  {
    path: 'canvas',
    loadComponent: () => loadRemoteModule('canvasRemote', './CanvasPanel')
      .then(m => m.CanvasPanel),
  },
];

Quick Start Guide

  1. Initialize Monorepo: Run pnpm create @orchestrator/monorepo to scaffold host, remotes, and shared contracts.
  2. Configure Federation: Add federation.config.mjs to each remote. Set skip for the contracts package.
  3. Build Remotes: Execute pnpm build:remotes. Verify remote-entry.json files are generated in each dist folder.
  4. Serve Host: Run pnpm serve:host. The host fetches manifests, injects import maps, and mounts remote components.
  5. Validate Communication: Open browser dev tools. Trigger events in remotes and confirm host receives payloads via the global channel.

Cross-framework composition is no longer a theoretical exercise. Native Federation v4 provides the runtime primitives to stitch heterogeneous UIs into a cohesive experience without forcing architectural conformity. By enforcing strict communication contracts, preventing singleton duplication, and guarding against framework-specific edge cases, teams can modernize incrementally while preserving existing investments.