architecture · scalability

Micro-Frontends: Breaking Large Apps Into Independent Modules

March 2025 · 15 min read for frontend architects & teams

❝ Micro-frontends extend the principles of microservices to the frontend world. Instead of a monolithic single-page application, you build your UI as a composition of loosely coupled, independently deployable modules.❞

In large organizations, frontend monoliths become bottlenecks: they slow down development, create dependency hell, and make incremental upgrades nearly impossible. Micro-frontends offer a way out—teams can own vertical slices of the product, choose their own tech stacks, and deploy autonomously. This article explores the core concepts, implementation strategies, real-world trade-offs, and provides code examples to get you started.

Why micro-frontends?

The monolithic pain

Classic SPAs (React, Angular, Vue) often grow into unmaintainable monoliths. A single repository with hundreds of components, tightly coupled business logic, and a single deployment pipeline creates friction. One small change can break the whole app, and coordination across dozens of developers becomes a nightmare.

The micro-frontend promise

Independent builds, isolated deployments, and team autonomy. Each micro-frontend can be developed, tested, and deployed separately. Teams can adopt new frameworks incrementally without rewriting the entire application. This aligns with modern DevOps and enables faster release cycles.

Core concepts & implementation approaches

There are several architectural patterns to compose micro-frontends. The most common ones include:

🏷️ 1. Build-time integration

Packages are published as libraries (npm) and installed by the host app. This is simple but loses runtime independence—updating a micro-frontend requires redeploying the shell.

⚙️ 2. Runtime integration via iFrames

Simple isolation but poor communication and UX (iframes are heavy). Rarely recommended for core applications.

📦 3. Webpack Module Federation

Webpack 5's Module Federation allows a JavaScript application to dynamically load code from another application at runtime. It's the most popular approach for micro-frontends today.

🌐 4. Web Components

Using custom elements to encapsulate each micro-frontend. Framework-agnostic, works natively, and can be composed by any shell.

Most common real-world stack: Module Federation + Single-SPA or custom orchestration. Module Federation enables true independent deployments while sharing dependencies efficiently.

Example: Module Federation in action

Let's build a simple shell (container) that consumes a remote "products" micro-frontend built with React. Both apps use Webpack 5 Module Federation.

📁 Remote app (products) – webpack.config.js

// products/webpack.config.js
const HtmlWebpackPlugin = require('html-webpack-plugin');
const ModuleFederationPlugin = require('webpack/lib/container/ModuleFederationPlugin');

module.exports = {
  mode: 'development',
  devServer: { port: 3001 },
  plugins: [
    new ModuleFederationPlugin({
      name: 'products',
      filename: 'remoteEntry.js',
      exposes: {
        './ProductsIndex': './src/index',      // expose component
      },
      shared: {
        react: { singleton: true, eager: true, requiredVersion: '^18.2.0' },
        'react-dom': { singleton: true, eager: true },
      },
    }),
    new HtmlWebpackPlugin({ template: './public/index.html' }),
  ],
};

📁 Shell app (container) – webpack.config.js

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

module.exports = {
  // ...
  plugins: [
    new ModuleFederationPlugin({
      name: 'container',
      remotes: {
        products: 'products@http://localhost:3001/remoteEntry.js',
      },
      shared: { react: { singleton: true }, 'react-dom': { singleton: true } },
    }),
  ],
};

📁 Using the remote component in the shell

// container/src/App.js
import React from 'react';

const ProductsApp = React.lazy(() => import('products/ProductsIndex'));

function App() {
  return (
    <div>
      <h1>Shell Application</h1>
      <React.Suspense fallback="Loading products...">
        <ProductsApp />
      </React.Suspense>
    </div>
  );
}

export default App;

This setup allows the products team to develop and deploy independently. The shell consumes the remote entry at runtime, fetching the latest version without rebuilding the entire application. Dependencies like React are shared to avoid duplication and maintain consistency.

Framework-agnostic with Web Components

If your teams use different frameworks (React, Vue, Svelte), Web Components provide a safe boundary. Each micro-frontend registers a custom element, and the host app simply mounts it.

// React micro-frontend wrapped as Web Component
import React from 'react';
import ReactDOM from 'react-dom/client';
import MyWidget from './MyWidget';

class MyWidgetElement extends HTMLElement {
  connectedCallback() {
    const root = ReactDOM.createRoot(this);
    root.render(<MyWidget />);
  }
}
customElements.define('my-widget', MyWidgetElement);
<!-- host app (any framework) -->
<my-widget></my-widget>
<script src="https://cdn.example.com/widget.js"></script>

This method ensures complete isolation, but communication between components requires custom events or shared state management (e.g., a global event bus).

Common challenges & how to tackle them

Global styling collisions

Different micro-frontends may have conflicting CSS. Solutions: use CSS-in-JS, CSS Modules, Shadow DOM (with Web Components), or enforce BEM naming conventions across teams. The container can also apply a CSS reset.

Cross-app communication

Sharing state (e.g., user authentication) is tricky. Common patterns: custom event bus, a shared Redux store via Module Federation, or using a global object with versioning. The container usually owns the global state and passes it via props or context.

Performance overhead

Loading multiple bundles increases initial payload. Use dependency sharing, lazy loading, and aggressive code splitting. Module Federation helps by avoiding duplicate libraries.

CI/CD & versioning

Independent pipelines require robust contract testing to ensure the shell works with new versions of remotes. Use tools like Pact or integration tests in staging environments.

Real-world inspiration

Major companies like Spotify, IKEA, and Zalando have adopted micro-frontends to scale their frontend development. Spotify's desktop app uses micro-frontends to separate features like playlist, search, and settings—each owned by independent squads. They achieve seamless user experience while enabling rapid experimentation. IKEA's website is composed of over 20 micro-frontends, allowing different teams to deploy changes multiple times per day without coordination overhead.

Key metric: At scale, micro-frontends reduced cross-team coordination time by ~60% and accelerated time-to-market for new features.

Orchestration: routing & composition

A central "shell" application manages routing and decides which micro-frontend to render for a given URL. Two main strategies exist:

A popular tool for orchestration is Single-SPA, which provides a meta-framework for registering and mounting different micro-frontends based on routes or activities.

// Single-SPA registration example
import { registerApplication, start } from 'single-spa';

registerApplication({
  name: '@myorg/products',
  app: () => System.import('@myorg/products'),
  activeWhen: ['/products'],
});

registerApplication({
  name: '@myorg/checkout',
  app: () => System.import('@myorg/checkout'),
  activeWhen: ['/checkout'],
});

start();

Is micro-frontends right for your team?

Consider micro-frontends if:
  • Your frontend codebase is large and multiple teams work on it.
  • You want independent release cycles and tech stack flexibility.
  • You already use microservices on the backend and want consistency.
  • You need to incrementally migrate legacy applications.
Maybe avoid if:
  • Your app is small (fewer than 5 developers) – the overhead isn't worth it.
  • You need pixel-perfect, tight integration between parts (e.g., a highly interactive canvas).
  • Your organization lacks DevOps maturity for independent pipelines.

Communication patterns between micro-frontends

Since micro-frontends are isolated, they need a way to exchange data. Common approaches:

// Event bus example (in shell)
window.addEventListener('cart-updated', (event) => {
  console.log('Cart count:', event.detail.count);
});

// In a micro-frontend
const event = new CustomEvent('cart-updated', { detail: { count: 3 } });
window.dispatchEvent(event);

Whichever pattern you choose, ensure versioning and backward compatibility to avoid runtime crashes.

Final thoughts: start small, think big

Micro-frontends are not a silver bullet. They introduce complexity in orchestration, communication, and infrastructure. However, for organizations that have outgrown monolithic frontends, they provide a path to sustainable scaling. Start with a pilot: extract a non-critical feature as a micro-frontend, set up Module Federation, and measure team velocity. Then gradually expand.

Remember that the ultimate goal is developer productivity and maintainability. If micro-frontends help your teams move faster with confidence, they are worth the investment. The ecosystem is maturing—with tools like Module Federation, Single-SPA, and Nx for monorepo management—making adoption smoother than ever.

In a world where frontend complexity grows exponentially, micro-frontends offer a way to keep things modular, resilient, and future-proof. Embrace the architecture that matches your organization’s evolution.

* The code examples are simplified for illustration. In production, consider error boundaries, version locking, and thorough integration testing.