Micro-Frontend Architecture: When and How to Scale Your Frontend

Decomposing large frontend applications into independently deployable modules—trade-offs, patterns, and implementation strategies.
Micro-frontends extend the microservices philosophy to the frontend: decompose a large, monolithic frontend application into smaller, independently deployable pieces that can be developed, tested, and deployed by separate teams. Like microservices, this architecture solves organizational problems more than technical ones—it's a strategy for large engineering organizations, not a solution for small teams. ## The Organizational Problem Micro-Frontends Solve In a large organization with multiple teams owning different product domains (checkout, catalog, account management, search), a monolithic frontend creates coordination friction. Every team's changes go through the same deployment pipeline. A broken change in the checkout team's code can block catalog team's release. Teams step on each other's shared component code. Scaling teams requires scaling coordination. Micro-frontends give each team full ownership of their domain's frontend: its own repository, its own CI/CD pipeline, its own deployment cadence, and its own technology choices. The shell application composes these fragments into a cohesive user experience. ## Integration Approaches **Module Federation (Webpack 5)**: The most widely adopted approach. Each micro-frontend exposes components or pages as remote modules that are loaded at runtime by the shell. Teams can deploy their remote independently, and the shell picks up new versions on next load. ```javascript // Shell webpack config new ModuleFederationPlugin({ name: 'shell', remotes: { checkout: 'checkout@https://checkout.example.com/remoteEntry.js', catalog: 'catalog@https://catalog.example.com/remoteEntry.js', }, }); // Shell usage const CheckoutPage = React.lazy(() => import('checkout/CheckoutPage')); ``` **iFrames**: The oldest and most isolated integration—each micro-frontend runs in its own iframe. Complete isolation (CSS, JS, runtime) prevents conflicts. Significant UX limitations: no shared navigation state, layout constraints, awkward inter-iframe communication. Generally not recommended for modern applications. **Web Components**: Each micro-frontend exposes custom elements (`<checkout-app>`, `<catalog-widget>`). Framework-agnostic and natively supported. Limitations: SSR is complex, style encapsulation via Shadow DOM can conflict with shared design systems. **Edge-Side Includes (ESI) / Server-Side Composition**: The server assembles the page from fragments served by different services. True separation—each fragment is an independent HTTP response. Works well for content-heavy, server-rendered pages. Less suitable for highly interactive applications where state must be shared across fragments. ## Shared Infrastructure Independent teams don't mean no coordination. Shared infrastructure that must be managed centrally: **Design System**: Shared component library and design tokens distributed as an npm package. Version pinning vs always-latest trade-off: always-latest ensures consistency but can introduce breaking changes unexpectedly; pinning gives stability but creates version drift. **Authentication and User Context**: A single auth provider that all micro-frontends integrate with. Share user context via a global state mechanism (Redux with shared store, custom event bus, or a React context provided by the shell). **Shared Dependencies**: Bundle React only once at the shell level rather than in every micro-frontend. Configure Module Federation's `shared` configuration to deduplicate heavy dependencies: ```javascript shared: { react: { singleton: true, requiredVersion: '^18.0.0' }, 'react-dom': { singleton: true, requiredVersion: '^18.0.0' }, } ``` ## Communication Between Micro-Frontends Micro-frontends should be loosely coupled. Communication patterns: **Custom Events**: Publish/subscribe via DOM custom events. Loose coupling—publishers don't know subscribers. Works across framework boundaries. **Shared State (Redux/Zustand)**: For complex state that multiple micro-frontends need (cart, user session), a shared state store hosted by the shell. Micro-frontends import and use the store. **URL**: Navigation state shared via URL. A micro-frontend in the catalog reads the current category from the URL; the checkout reads the cart ID from a URL parameter. No direct coupling. **Avoid**: Direct function calls between micro-frontends or importing components from one micro-frontend's runtime into another—this creates tight coupling that defeats the purpose of decomposition. ## When Not to Use Micro-Frontends Micro-frontends are not a silver bullet and carry significant costs: - Increased initial JavaScript payload (multiple bundles loaded separately) - Operational complexity (multiple deployment pipelines, version coordination) - User experience fragmentation if not carefully designed - Performance overhead from loading remote modules at runtime For teams smaller than 50 engineers or products smaller than ~5 distinct domains, a well-structured monorepo with clear module boundaries delivers most of the organizational benefits with far less complexity. Use micro-frontends when team autonomy and independent deployment are genuinely blocked by shared codebase coordination.
