Back to blog
Advertisement

Microfrontends: The Architecture Giant Companies Use to Scale Complex Applications

Hello HaWkers, have you ever tried working on a frontend project with 50+ developers where every change caused endless merge conflicts?

Spotify has over 200 squads working simultaneously on the same product. IKEA manages dozens of different applications that need to look like one. Amazon coordinates thousands of developers building independent features. How do these companies manage to scale without creating complete chaos? The answer is microfrontends.

The Problem Microfrontends Solve

Imagine a traditional e-commerce application built as a frontend monolith: product catalog, cart, checkout, user area, admin dashboard - all in a single giant React repository.

Now imagine 100 developers working on this code. Each deploy needs to pass through thousands of tests. A change in checkout can break the catalog. Different teams need to coordinate deploys. The final bundle is 5MB. Build takes 20 minutes.

This is frontend monolith hell, and it's exactly what many companies face when they grow.

Microfrontends apply microservices principles to the frontend: breaking the application into smaller, independent pieces that can be developed, tested, and deployed separately by autonomous teams.

Advertisement

Understanding Microfrontend Architecture

At its core, microfrontends means breaking your application into multiple independent sub-applications, each possibly using different technologies.

Basic Architecture:

// container-app/src/App.jsx - Container application
import React, { lazy, Suspense } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';

// Dynamic microfrontend imports
const ProductCatalog = lazy(() => import('catalog/ProductCatalog'));
const ShoppingCart = lazy(() => import('cart/ShoppingCart'));
const Checkout = lazy(() => import('checkout/Checkout'));
const UserDashboard = lazy(() => import('user/Dashboard'));

function App() {
  return (
    <BrowserRouter>
      <div className="app">
        {/* Shared header */}
        <Header />

        {/* Each route loads a different microfrontend */}
        <Suspense fallback={<Loading />}>
          <Routes>
            <Route path="/" element={<ProductCatalog />} />
            <Route path="/cart" element={<ShoppingCart />} />
            <Route path="/checkout" element={<Checkout />} />
            <Route path="/user/*" element={<UserDashboard />} />
          </Routes>
        </Suspense>

        {/* Shared footer */}
        <Footer />
      </div>
    </BrowserRouter>
  );
}

export default App;

Each microfrontend (catalog, cart, checkout, user) is a separate application with its own repository, build, and deploy.

Module Federation: The Game Changer

Webpack 5 introduced Module Federation, which revolutionized microfrontends by allowing code sharing at runtime without needing to republish everything.

Host Configuration (Container):

// container-app/webpack.config.js
const ModuleFederationPlugin = require('webpack/lib/container/ModuleFederationPlugin');

module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: 'container',
      remotes: {
        // URLs of remote microfrontends
        catalog: 'catalog@http://localhost:3001/remoteEntry.js',
        cart: 'cart@http://localhost:3002/remoteEntry.js',
        checkout: 'checkout@http://localhost:3003/remoteEntry.js',
        user: 'user@http://localhost:3004/remoteEntry.js'
      },
      shared: {
        // Shared dependencies
        react: {
          singleton: true,
          requiredVersion: '^18.2.0',
          eager: true
        },
        'react-dom': {
          singleton: true,
          requiredVersion: '^18.2.0',
          eager: true
        },
        'react-router-dom': {
          singleton: true,
          requiredVersion: '^6.8.0'
        }
      }
    })
  ]
};

Remote Configuration (Microfrontend):

// catalog-app/webpack.config.js
const ModuleFederationPlugin = require('webpack/lib/container/ModuleFederationPlugin');

module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: 'catalog',
      filename: 'remoteEntry.js',
      exposes: {
        // Components exposed to other apps
        './ProductCatalog': './src/ProductCatalog',
        './ProductCard': './src/components/ProductCard',
        './useProducts': './src/hooks/useProducts'
      },
      shared: {
        react: { singleton: true, requiredVersion: '^18.2.0' },
        'react-dom': { singleton: true, requiredVersion: '^18.2.0' }
      }
    })
  ]
};

With this, the container can load the catalog dynamically at runtime. If you deploy a new version of the catalog, users will see the change instantly without needing to update the container.

Advertisement

Communication Between Microfrontends

One of the biggest challenges is making independent microfrontends communicate. There are several approaches.

Event Bus Pattern:

// shared/eventBus.js - Shared event system
class EventBus {
  constructor() {
    this.events = {};
  }

  subscribe(eventName, callback) {
    if (!this.events[eventName]) {
      this.events[eventName] = [];
    }

    this.events[eventName].push(callback);

    // Returns unsubscribe function
    return () => {
      this.events[eventName] = this.events[eventName].filter(
        cb => cb !== callback
      );
    };
  }

  publish(eventName, data) {
    if (!this.events[eventName]) return;

    this.events[eventName].forEach(callback => {
      try {
        callback(data);
      } catch (error) {
        console.error(`Error in event handler for ${eventName}:`, error);
      }
    });
  }

  clear(eventName) {
    if (eventName) {
      delete this.events[eventName];
    } else {
      this.events = {};
    }
  }
}

// Global singleton
window.__SHARED_EVENT_BUS__ = window.__SHARED_EVENT_BUS__ || new EventBus();

export default window.__SHARED_EVENT_BUS__;

Usage in Cart Microfrontend:

// cart-app/src/Cart.jsx
import React, { useState, useEffect } from 'react';
import eventBus from 'shared/eventBus';

function ShoppingCart() {
  const [items, setItems] = useState([]);

  useEffect(() => {
    // Listen for "add to cart" events
    const unsubscribe = eventBus.subscribe('cart:addItem', (product) => {
      setItems(prev => {
        const existing = prev.find(item => item.id === product.id);

        if (existing) {
          // Increment quantity if already exists
          return prev.map(item =>
            item.id === product.id
              ? { ...item, quantity: item.quantity + 1 }
              : item
          );
        }

        // Add new item
        return [...prev, { ...product, quantity: 1 }];
      });

      // Notify other microfrontends
      eventBus.publish('cart:updated', items.length + 1);
    });

    // Cleanup
    return unsubscribe;
  }, [items]);

  const removeItem = (itemId) => {
    const newItems = items.filter(item => item.id !== itemId);
    setItems(newItems);
    eventBus.publish('cart:updated', newItems.length);
  };

  const updateQuantity = (itemId, quantity) => {
    if (quantity <= 0) {
      removeItem(itemId);
      return;
    }

    setItems(prev =>
      prev.map(item =>
        item.id === itemId ? { ...item, quantity } : item
      )
    );
  };

  const total = items.reduce((sum, item) => {
    return sum + (item.price * item.quantity);
  }, 0);

  return (
    <div className="shopping-cart">
      <h2>Cart ({items.length})</h2>

      {items.length === 0 ? (
        <p>Empty cart</p>
      ) : (
        <>
          {items.map(item => (
            <div key={item.id} className="cart-item">
              <img src={item.image} alt={item.name} />
              <div>
                <h3>{item.name}</h3>
                <p>${item.price.toFixed(2)}</p>
              </div>
              <div>
                <button onClick={() => updateQuantity(item.id, item.quantity - 1)}>
                  -
                </button>
                <span>{item.quantity}</span>
                <button onClick={() => updateQuantity(item.id, item.quantity + 1)}>
                  +
                </button>
              </div>
              <button onClick={() => removeItem(item.id)}>Remove</button>
            </div>
          ))}

          <div className="cart-total">
            <h3>Total: ${total.toFixed(2)}</h3>
            <button onClick={() => eventBus.publish('checkout:start', items)}>
              Checkout
            </button>
          </div>
        </>
      )}
    </div>
  );
}

export default ShoppingCart;

Usage in Catalog Microfrontend:

// catalog-app/src/ProductCard.jsx
import React from 'react';
import eventBus from 'shared/eventBus';

function ProductCard({ product }) {
  const addToCart = () => {
    // Publish event that cart will listen to
    eventBus.publish('cart:addItem', product);

    // Visual feedback
    showNotification(`${product.name} added to cart!`);
  };

  return (
    <div className="product-card">
      <img src={product.image} alt={product.name} />
      <h3>{product.name}</h3>
      <p className="description">{product.description}</p>
      <p className="price">${product.price.toFixed(2)}</p>
      <button onClick={addToCart}>Add to Cart</button>
    </div>
  );
}

This pattern allows microfrontends to communicate without knowing each other's implementation details.

Advertisement

Shared State Between Microfrontends

For global state that needs to be shared, you can use several strategies.

Shared Store with Zustand:

// shared/stores/userStore.js
import create from 'zustand';

// Store shared among all microfrontends
const useUserStore = create((set, get) => ({
  user: null,
  isAuthenticated: false,

  login: async (credentials) => {
    try {
      const response = await fetch('/api/auth/login', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(credentials)
      });

      const user = await response.json();

      set({
        user,
        isAuthenticated: true
      });

      // Notify other microfrontends
      window.dispatchEvent(new CustomEvent('user:login', { detail: user }));

      return user;
    } catch (error) {
      console.error('Login failed:', error);
      throw error;
    }
  },

  logout: () => {
    set({
      user: null,
      isAuthenticated: false
    });

    window.dispatchEvent(new CustomEvent('user:logout'));
  },

  updateProfile: (updates) => {
    const user = get().user;

    if (!user) return;

    const updatedUser = { ...user, ...updates };

    set({ user: updatedUser });

    window.dispatchEvent(
      new CustomEvent('user:updated', { detail: updatedUser })
    );
  }
}));

// Expose globally to all microfrontends
window.__USER_STORE__ = useUserStore;

export default useUserStore;

Each microfrontend can import and use this store, maintaining automatic synchronization.

Challenges and Solutions

Microfrontends aren't a silver bullet. They come with their own challenges.

Performance and Bundle Size

Each microfrontend adds overhead. If you're not careful, you can end up with multiple copies of React being downloaded.

Solution: Use shared in Module Federation aggressively and implement intelligent code splitting.

Complex Debugging

Debugging issues that cross multiple microfrontends is harder.

Solution: Invest in structured logging, distributed tracing, and tools like Sentry with microfrontend context.

Versioning

Ensuring compatibility between versions of different microfrontends is challenging.

Solution: Define clear API contracts, use semantic versioning rigorously, and implement integration tests between microfrontends.

UI Consistency

Maintaining consistent design system between autonomous teams requires discipline.

Solution: Shared design system as versioned npm library, with CI/CD to ensure updates.

Advertisement

When to Use (and When Not to Use) Microfrontends

Microfrontends are powerful but add significant complexity.

Use Microfrontends when:

  • You have multiple autonomous teams (10+ developers)
  • Application is large and naturally divisible
  • Need to scale teams independently
  • Different parts have distinct release cycles
  • Want to experiment with different technologies in parts of the application

Don't use when:

  • Team is small (less than 10 devs)
  • Application is relatively simple
  • Everyone works in the same context
  • Don't have infrastructure to support multiple deploys

For most projects, a well-structured monolith is simpler and sufficient. Microfrontends are for when you already have the problems they solve.

The Future of Microfrontends

Tools are making microfrontends increasingly accessible. Single-SPA, Nx, Turborepo, and frameworks like Qwik with native lazy loading are pushing the limits.

Module Federation 2.0 promises massive improvements in performance and developer experience. And platforms like Vercel and Netlify are starting to offer optimized deployment for microfrontends.

If you're building complex and scalable applications, I also recommend exploring Serverless with JavaScript, another architecture that combines perfectly with microfrontends to create truly elastic systems.

Let's go! πŸ¦…

πŸ“š Want to Deepen Your JavaScript Knowledge?

This article covered microfrontends and scalable architecture, but there's much more to explore in modern development.

Developers who invest in solid, structured knowledge tend to have more opportunities in the market.

Complete Study Material

If you want to master JavaScript from basics to advanced, I've prepared a complete guide:

Investment options:

  • 2x of $13.08 on card
  • or $24.90 at sight

πŸ‘‰ Learn About JavaScript Guide

πŸ’‘ Material updated with industry best practices

Advertisement
Previous postNext post

Comments (0)

This article has no comments yet 😒. Be the first! πŸš€πŸ¦…

Add comments