Skip to content
Ayhan Sipahi Ayhan Sipahi

Micro Frontend Architecture Fundamentals: From Monolith to Distributed Systems

Micro frontend composition strategies compared: server-side, build-time, Module Federation, and iframe, with the team preconditions that make the trade pay off.

Micro frontend architectures split a single-page frontend into independently deployable, independently owned slices that compose at runtime. They solve a specific set of problems (team-size scaling, independent release cadence, technology flexibility) and introduce a corresponding set of new ones (runtime composition complexity, shared-dependency management, cross-slice state, cross-slice performance). The choice to adopt them is rarely a clean win; it is a trade of coordination overhead for release autonomy, and the trade only pays back at certain team sizes and product-structure boundaries.

The safe default is the simplest composition your team boundaries allow: build-time integration inside a monorepo. Runtime composition waits until independent release cadence becomes the binding constraint. The sections below start from that default. They cover the four composition strategies (server-side, build-time, runtime via Module Federation, iframe), the shared-dependency patterns each one forces, and the preconditions that make the trade worth paying for.

Micro Frontend Series Navigation

  • Part 1 (You are here): Architecture fundamentals and implementation types
  • Part 2: Module Federation, communication patterns, and integration strategies
  • Part 3: Advanced patterns, performance optimization, and production debugging

What Are Micro Frontends?

Micro frontends extend the microservices concept to frontend development. Instead of a single monolithic frontend application, you compose multiple smaller, independently deployable frontend applications into a cohesive user experience.

The key principles are:

  • Technology Agnostic: Teams can choose their own frameworks and tools
  • Independent Deployment: Each micro frontend can be deployed independently
  • Team Autonomy: Different teams can own different parts of the application
  • Incremental Migration: Gradual migration from monoliths is possible

Types of Micro Frontend Architectures

Here are the four main architectural patterns, each with distinct characteristics and use cases:

1. Server-Side Template Composition

The simplest approach where different services render HTML fragments that are composed on the server.

// Gateway service composing multiple micro frontends
import express from 'express';
import fetch from 'node-fetch';

const app = express();

app.get('/', async (req, res) => {
  try {
    // Fetch fragments from different services
    const [header, navigation, content, footer] = await Promise.all([
      fetch('http://header-service/fragment').then(r => r.text()),
      fetch('http://nav-service/fragment').then(r => r.text()),
      fetch('http://content-service/fragment').then(r => r.text()),
      fetch('http://footer-service/fragment').then(r => r.text())
    ]);

    const html = `
      <!DOCTYPE html>
      <html>
        <head>
          <title>Composed Application</title>
        </head>
        <body>
          ${header}
          ${navigation}
          <main>${content}</main>
          ${footer}
        </body>
      </html>
    `;

    res.send(html);
  } catch (error) {
    res.status(500).send('Error composing page');
  }
});

Pros: Simple to understand, good SEO, works without JavaScript Cons: Limited interactivity, page refreshes for navigation, shared state challenges

When to use: Content-heavy sites, when SEO is critical, teams comfortable with server-side development

2. Build-Time Integration

Micro frontends are published as npm packages and composed at build time. The shell pins each one as a versioned dependency:

{
  "dependencies": {
    "@company/header-mf": "^1.2.0",
    "@company/product-catalog-mf": "^2.1.5",
    "@company/checkout-mf": "^1.8.2"
  }
}

The shell then imports them like any other package:

// Shell application
import React from 'react';
import { Header } from '@company/header-mf';
import { ProductCatalog } from '@company/product-catalog-mf';
import { Checkout } from '@company/checkout-mf';

const App: React.FC = () => {
  return (
    <div>
      <Header />
      <main>
        <ProductCatalog />
        <Checkout />
      </main>
    </div>
  );
};

export default App;
// Micro frontend package (header-mf)
import React from 'react';

export interface HeaderProps {
  user?: {
    name: string;
    avatar: string;
  };
  onLogout?: () => void;
}

export const Header: React.FC<HeaderProps> = ({ user, onLogout }) => {
  return (
    <header className="bg-blue-600 text-white p-4">
      <div className="flex justify-between items-center">
        <h1>My App</h1>
        {user && (
          <div className="flex items-center gap-2">
            <img src={user.avatar} alt={user.name} className="w-8 h-8 rounded-full" />
            <span>{user.name}</span>
            <button onClick={onLogout}>Logout</button>
          </div>
        )}
      </div>
    </header>
  );
};

Pros: Type safety, shared dependencies optimization, familiar development experience Cons: Coordinated deployments, version management complexity, not truly independent

When to use: When you want micro frontend benefits but can tolerate coordinated deployments

3. Runtime Integration via JavaScript

The most flexible approach where micro frontends are loaded and integrated at runtime.

// Micro frontend registry
declare const __webpack_init_sharing__: (scope: string) => Promise<void>;
declare const __webpack_share_scopes__: Record<string, any>;

interface MicroFrontendConfig {
  name: string;
  url: string;
  scope: string;
  module: string;
}

class MicroFrontendRegistry {
  private configs: Map<string, MicroFrontendConfig> = new Map();
  private loadedModules: Map<string, any> = new Map();

  register(config: MicroFrontendConfig) {
    this.configs.set(config.name, config);
  }

  async load(name: string): Promise<any> {
    if (this.loadedModules.has(name)) {
      return this.loadedModules.get(name);
    }

    const config = this.configs.get(name);
    if (!config) {
      throw new Error(`Micro frontend ${name} not registered`);
    }

    // Dynamic import with error handling
    try {
      await this.loadScript(config.url);
      await __webpack_init_sharing__('default');
      const container = (window as any)[config.scope];

      if (!container) {
        throw new Error(`Container ${config.scope} not found`);
      }

      // The remote joins the host share scope, so react stays a singleton
      await container.init(__webpack_share_scopes__.default);

      const factory = await container.get(config.module);
      const Module = factory();

      this.loadedModules.set(name, Module);
      return Module;
    } catch (error) {
      console.error(`Failed to load micro frontend ${name}:`, error);
      throw error;
    }
  }

  private loadScript(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 script: ${url}`));
      document.head.appendChild(script);
    });
  }
}

// Usage in shell application
const registry = new MicroFrontendRegistry();

registry.register({
  name: 'product-catalog',
  url: 'http://localhost:3001/remoteEntry.js',
  scope: 'productCatalog',
  module: './ProductCatalog'
});

const DynamicMicroFrontend: React.FC<{ name: string }> = ({ name }) => {
  const [Component, setComponent] = useState<React.ComponentType | null>(null);
  const [error, setError] = useState<string | null>(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    registry.load(name)
      .then(Module => {
        setComponent(() => Module.default || Module);
        setError(null);
      })
      .catch(err => {
        setError(err.message);
        setComponent(null);
      })
      .finally(() => setLoading(false));
  }, [name]);

  if (loading) return <div>Loading {name}...</div>;
  if (error) return <div>Error loading {name}: {error}</div>;
  if (!Component) return <div>Component {name} not found</div>;

  return <Component />;
};

Pros: True independence, different technology stacks possible, runtime flexibility Cons: Complexity, runtime errors, performance overhead, debugging challenges

When to use: Large organizations with multiple teams, need for technology diversity

4. Iframe-Based Integration

The most isolated approach using iframes for complete separation.

// Iframe micro frontend wrapper with postMessage communication
interface IframeMicroFrontendProps {
  src: string;
  name: string;
  onMessage?: (data: any) => void;
}

const IframeMicroFrontend: React.FC<IframeMicroFrontendProps> = ({
  src,
  name,
  onMessage
}) => {
  const iframeRef = useRef<HTMLIFrameElement>(null);
  const [isLoaded, setIsLoaded] = useState(false);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    const handleMessage = (event: MessageEvent) => {
      // Verify origin for security
      if (event.origin !== new URL(src).origin) {
        return;
      }

      if (event.data.source === name) {
        onMessage?.(event.data.payload);
      }
    };

    window.addEventListener('message', handleMessage);
    return () => window.removeEventListener('message', handleMessage);
  }, [src, name, onMessage]);

  const sendMessage = (data: any) => {
    if (iframeRef.current?.contentWindow) {
      iframeRef.current.contentWindow.postMessage({
        source: 'shell',
        target: name,
        payload: data
      }, new URL(src).origin);
    }
  };

  return (
    <div className="micro-frontend-container">
      {!isLoaded && <div>Loading {name}...</div>}
      {error && <div>Error: {error}</div>}
      <iframe
        ref={iframeRef}
        src={src}
        onLoad={() => setIsLoaded(true)}
        onError={() => setError(`Failed to load ${name}`)}
        style={{
          width: '100%',
          border: 'none',
          minHeight: '400px'
        }}
        title={name}
        sandbox="allow-scripts allow-same-origin allow-forms"
      />
    </div>
  );
};

// Inside the micro frontend (iframe content)
const SHELL_ORIGIN = 'https://shell.example.com';

const MicroFrontendApp: React.FC = () => {
  const [data, setData] = useState<any>(null);

  useEffect(() => {
    const handleMessage = (event: MessageEvent) => {
      if (event.origin !== SHELL_ORIGIN) {
        return;
      }

      if (event.data.target === 'product-catalog') {
        setData(event.data.payload);
      }
    };

    window.addEventListener('message', handleMessage);
    return () => window.removeEventListener('message', handleMessage);
  }, []);

  const sendDataToShell = (payload: any) => {
    // Name the shell origin instead of posting to '*'
    window.parent.postMessage({
      source: 'product-catalog',
      payload
    }, SHELL_ORIGIN);
  };

  return (
    <div>
      <h2>Product Catalog Micro Frontend</h2>
      {/* Your micro frontend content */}
    </div>
  );
};

Pros: Complete isolation, security, different domains possible, CSS isolation Cons: Limited communication, SEO challenges, performance overhead, UX considerations

When to use: Security is paramount, legacy integration, third-party content

Debugging Case Study: The Vanishing Styles Problem

A common micro frontend pitfall surfaces when a runtime integration system works perfectly in development but produces missing styles in production. Consider a product catalog micro frontend where symptoms look like this:

  • Styles work fine when the micro frontend runs standalone
  • The issue only occurs in production, not development
  • It is intermittent: sometimes styles load, sometimes they do not

Investigation points to the root cause: CSS loading race conditions.

// The problematic code
const ProductCatalogMF: React.FC = () => {
  useEffect(() => {
    // This was loading CSS after component mount
    import('./styles.css');
  }, []);

  return <div className="product-grid">...</div>;
};

In production, with more aggressive minification and CDN caching, the CSS import completes after the component has already rendered. The solution requires a more robust loading strategy:

// Fixed version with proper CSS loading
const MicroFrontendLoader = {
  async loadWithStyles(name: string, cssUrls: string[] = []) {
    // Load CSS first
    await Promise.all(
      cssUrls.map(url => this.loadStylesheet(url))
    );

    // Then load the component
    return await registry.load(name);
  },

  loadStylesheet(url: string): Promise<void> {
    return new Promise((resolve, reject) => {
      // Check if already loaded
      if (document.querySelector(`link[href="${url}"]`)) {
        resolve();
        return;
      }

      const link = document.createElement('link');
      link.rel = 'stylesheet';
      link.href = url;
      link.onload = () => resolve();
      link.onerror = () => reject(new Error(`Failed to load CSS: ${url}`));
      document.head.appendChild(link);
    });
  }
};

This failure pattern underscores three implementation requirements:

  1. Proper loading sequences in micro frontend architectures
  2. Environment parity: production issues often do not manifest in development
  3. Monitoring and observability: CSS load tracking catches these issues early

Performance Considerations

Micro frontends introduce unique performance challenges:

Bundle Size and Duplication

Multiple micro frontends often ship the same dependencies, leading to bloated bundles.

// Webpack configuration for shared dependencies
// Module Federation 2.0 ships as @module-federation/enhanced, not the webpack built-in
const { ModuleFederationPlugin } = require('@module-federation/enhanced/webpack');

module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: 'shell',
      remotes: {
        productCatalog: 'productCatalog@http://localhost:3001/remoteEntry.js',
      },
      shared: {
        react: {
          singleton: true,
          requiredVersion: '^18.0.0',
        },
        'react-dom': {
          singleton: true,
          requiredVersion: '^18.0.0',
        },
        // Share common utilities
        lodash: {
          singleton: false, // Allow multiple versions if needed
        }
      },
    }),
  ],
};

Loading Performance

Implement progressive loading strategies:

// Progressive micro frontend loading
const ProgressiveMicroFrontend: React.FC<{
  name: string;
  priority: 'high' | 'medium' | 'low';
}> = ({ name, priority }) => {
  const [shouldLoad, setShouldLoad] = useState(priority === 'high');
  const isVisible = useIntersectionObserver();

  useEffect(() => {
    if (priority === 'medium' && isVisible) {
      setShouldLoad(true);
    } else if (priority === 'low') {
      // Load after main content is ready
      const timer = setTimeout(() => setShouldLoad(true), 2000);
      return () => clearTimeout(timer);
    }
  }, [isVisible, priority]);

  if (!shouldLoad) {
    return <div>Loading {name}...</div>;
  }

  return <DynamicMicroFrontend name={name} />;
};

Choosing the Right Architecture

The choice depends on your specific constraints:

FactorServer-SideBuild-TimeRuntimeIframe
Team IndependenceLowMediumHighHigh
Technology DiversityMediumLowHighHigh
PerformanceHighHighMediumLow
ComplexityLowMediumHighMedium
SEOExcellentGoodPoorPoor
Development ExperienceGoodExcellentMediumPoor

Next in the Series

The four patterns above are the decision surface. The cost of the choice lands in the integration layer.

Continue to Part 2: Module Federation and Implementation Patterns for:

  • Production-ready Module Federation configurations
  • Robust error handling and fallback strategies
  • Cross-micro frontend communication patterns
  • Routing coordination between applications
  • Development workflows and tooling
  • Debugging walkthroughs for common runtime failures

Build-time integration inside a monorepo stays the right default while one release train can carry the whole frontend. Override it when separate teams need to ship on separate days, when a legacy stack has to live beside a new one, or when third-party content must stay sandboxed. At that point pick the composition model from the table above rather than reaching for runtime loading by reflex.


Series Navigation

  • Part 1 (Current): Architecture fundamentals
  • Part 2: Implementation patterns
  • Part 3: Advanced patterns & debugging

References

Micro Frontend Architecture Guide

A 3-part comprehensive guide to micro frontend architecture, from fundamental concepts to advanced patterns and production debugging strategies.

Progress 1/3 posts completed

All Posts in This Series

Related posts