Building Scalable Web Apps with Micro-Frontend Architecture

A practical guide to implementing micro-frontends with Module Federation, covering architecture decisions, team scaling, and lessons learned from production.

December 28, 2022β€’ 8 min readβ€’ Nasir Movlamov
Web DevelopmentArchitectureMicro-frontendsJavaScript

Building Scalable Web Apps with Micro-Frontend Architecture

Monolithic frontends are breaking under their own weight. As applications grow, build times increase, deployment becomes risky, and teams step on each other's toes. Micro-frontends offer a solutionβ€”but they're not a silver bullet.

After implementing micro-frontend architecture in production, here's what actually works.

The Problem with Monoliths

Let's be honest about why we're here. Traditional single-page applications face real problems at scale:

Build times explode: What started as 30-second builds become 10+ minute marathons. Developer experience suffers, productivity drops.

Deployment anxiety: Changing one line requires redeploying everything. A bug in feature A breaks feature B. Friday deployments become terrifying.

Team bottlenecks: Multiple teams working in one codebase leads to:

  • Merge conflicts
  • Blocking code reviews
  • Shared dependencies causing breakage
  • Coordination overhead

Technology lock-in: Chosen React in 2018? You're stuck with it. Want to try Svelte for a new feature? Tough luck.

Domain coupling: User management code sits next to payment processing sits next to analytics. Boundaries blur. Complexity grows.

Sound familiar? Let's talk solutions.

What Are Micro-Frontends?

Micro-frontends apply microservice principles to frontend development:

Monolithic approach: One large application, one deployment, one team (or many teams fighting over one codebase).

Micro-frontend approach: Multiple independent applications, integrated at runtime, owned by separate teams.

Think of it like this: Instead of one huge apartment building, you have a neighborhood of smaller houses. Each house is independently managed, but they all work together as a community.

Architecture Patterns

There are several ways to implement micro-frontends:

1. Runtime Integration (Module Federation)

Applications are built separately, loaded dynamically at runtime.

Pros:

  • Independent deployments
  • Shared dependencies optimized
  • True runtime composition

Cons:

  • Complex setup
  • Runtime overhead
  • Dependency management challenges

This is what we use, via Webpack Module Federation.

2. Build-Time Integration (NPM packages)

Micro-frontends published as packages, integrated at build time.

Pros:

  • Simple setup
  • Familiar workflow
  • Type safety

Cons:

  • Not truly independent (need rebuild)
  • Version management complexity
  • Loses core benefit of micro-frontends

3. Server-Side Composition (ESI, SSI)

Server assembles page from different applications.

Pros:

  • SEO friendly
  • Progressive enhancement
  • No client-side complexity

Cons:

  • Server complexity
  • Less interactive
  • Caching challenges

4. iFrame-Based (Old but simple)

Each micro-frontend in its own iframe.

Pros:

  • Complete isolation
  • Simple to implement
  • Any technology stack

Cons:

  • Performance overhead
  • Routing complications
  • Poor user experience
  • Communication complexity

We chose runtime integration with Module Federation. Here's why and how.

Our Implementation: Module Federation

Webpack 5's Module Federation changed the game. It allows:

  • Loading remote applications at runtime
  • Sharing dependencies between apps
  • Version compatibility management
  • True independent deployment

Architecture Overview

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚      Shell Application (Host)      β”‚
β”‚  - Routing                          β”‚
β”‚  - Authentication                   β”‚
β”‚  - Shared UI components             β”‚
β”‚  - Layout                           β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
           β”‚
           β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
           β”‚              β”‚              β”‚              β”‚
    β”Œβ”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”
    β”‚ User Module β”‚ β”‚Payment    β”‚ β”‚Analytics  β”‚ β”‚Admin      β”‚
    β”‚ (Remote)    β”‚ β”‚Module     β”‚ β”‚Module     β”‚ β”‚Module     β”‚
    β”‚             β”‚ β”‚(Remote)   β”‚ β”‚(Remote)   β”‚ β”‚(Remote)   β”‚
    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Configuration Example

Shell Application (Host):

// webpack.config.js
module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: 'shell',
      remotes: {
        user: 'user@http://localhost:3001/remoteEntry.js',
        payment: 'payment@http://localhost:3002/remoteEntry.js',
        analytics: 'analytics@http://localhost:3003/remoteEntry.js',
      },
      shared: {
        react: { singleton: true, requiredVersion: '^18.0.0' },
        'react-dom': { singleton: true, requiredVersion: '^18.0.0' },
        'react-router-dom': { singleton: true },
      },
    }),
  ],
};

Remote Application:

// webpack.config.js
module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: 'user',
      filename: 'remoteEntry.js',
      exposes: {
        './UserModule': './src/UserModule',
        './UserProfile': './src/components/UserProfile',
      },
      shared: {
        react: { singleton: true, requiredVersion: '^18.0.0' },
        'react-dom': { singleton: true, requiredVersion: '^18.0.0' },
      },
    }),
  ],
};

Loading Remote Modules

// Dynamic import in shell
const UserModule = React.lazy(() => import('user/UserModule'));

function App() {
  return (
    <Suspense fallback={<Loading />}>
      <UserModule />
    </Suspense>
  );
}

Key Design Decisions

1. Shared vs. Independent Dependencies

Rule: Share framework code (React), make business logic independent.

Why:

  • Avoids loading React multiple times
  • Allows independent feature development
  • Balances bundle size with autonomy

2. Communication Between Modules

Options:

  • Custom events
  • Shared state management
  • Props drilling
  • Event bus

Our choice: Custom events + shared auth state.

// Publish event
window.dispatchEvent(
  new CustomEvent('user:updated', {
    detail: { userId: 123 },
  })
);

// Subscribe to event
useEffect(() => {
  const handler = (e) => {
    console.log('User updated:', e.detail);
  };
  window.addEventListener('user:updated', handler);
  return () => window.removeEventListener('user:updated', handler);
}, []);

3. Routing Strategy

Challenge: Who owns routing?

Our approach:

  • Shell owns top-level routes
  • Modules own sub-routes
  • URL structure: /module-name/feature/sub-feature
// Shell routing
<Route path="/user/*" element={<UserModule />} />
<Route path="/payment/*" element={<PaymentModule />} />

// User module internal routing
<Routes>
  <Route path="profile" element={<Profile />} />
  <Route path="settings" element={<Settings />} />
</Routes>

4. Shared Component Library

Centralized design system prevents UI fragmentation:

// In shell's shared config
shared: {
  '@company/ui-components': {
    singleton: true,
    requiredVersion: '^2.0.0',
  },
}

Team Organization

Micro-frontends enable team autonomy:

Team User: Owns user management module

  • Authentication
  • Profile management
  • User settings

Team Payment: Owns payment module

  • Checkout flow
  • Payment methods
  • Transaction history

Team Analytics: Owns analytics module

  • Dashboards
  • Reports
  • Data visualization

Team Platform: Owns shell application

  • Routing infrastructure
  • Authentication framework
  • Shared UI components
  • Deployment pipeline

Each team can:

  • Choose their tools (within constraints)
  • Deploy independently
  • Work at their own pace
  • Own their domain completely

Benefits We've Seen

1. Faster Deployments

Before: 45-minute builds, risky deployments After: 5-minute module builds, safe incremental deployments

2. Team Velocity

Before: Teams blocked by others, coordination overhead After: Teams move independently, higher throughput

3. Technology Flexibility

Before: Stuck with legacy choices After: New modules can use newer tools

4. Clearer Ownership

Before: Shared responsibility = no responsibility After: Clear boundaries, accountable teams

Challenges and Solutions

Challenge 1: Version Management

Problem: Module A needs React 18.1, Module B needs 18.2. What happens?

Solution:

  • Strict shared dependency versioning
  • Regular dependency updates across modules
  • Version compatibility matrix

Challenge 2: Testing

Problem: How do you test independently deployed modules together?

Solution:

  • Contract testing between modules
  • Integration environment with all modules
  • End-to-end tests in CI/CD
  • Canary deployments for validation

Challenge 3: Development Experience

Problem: Running all modules locally is resource-intensive.

Solution:

  • Mock remotes in development
  • Point to deployed modules for dependencies
  • Containerized local development environment

Challenge 4: Initial Complexity

Problem: Micro-frontends add architectural complexity.

Solution:

  • Start simple, evolve gradually
  • Document patterns and decisions
  • Provide boilerplate templates
  • Invest in tooling and automation

When NOT to Use Micro-Frontends

Micro-frontends aren't always the answer:

Don't use if:

  • Your team is small (< 10 developers)
  • Application is simple
  • No need for independent deployments
  • Limited technical expertise

Do use if:

  • Multiple teams working on frontend
  • Different deployment schedules needed
  • Application is large and growing
  • Organizational boundaries align with features

Lessons Learned

1. Start with a Monolith

Don't prematurely optimize. Build a monolith first, extract micro-frontends when pain points emerge.

2. Boundaries Matter

Choose module boundaries carefully:

  • Align with business domains
  • Consider team structure
  • Think long-term

Bad boundaries cause more problems than they solve.

3. Communication is Key

With independent teams comes coordination overhead. Over-communicate:

  • Breaking changes
  • Shared dependency updates
  • Architecture decisions

4. Invest in Platform

The shell application becomes critical infrastructure. Invest in:

  • Developer experience
  • Documentation
  • Tooling
  • Monitoring

5. Monitor Everything

Distributed systems need observability:

  • Module load times
  • Error rates per module
  • Performance metrics
  • Version tracking

The Future

Micro-frontends are evolving:

Native Module Federation: Browser-native support for module loading.

Web Components: Standard-based micro-frontend composition.

Edge-Side Rendering: Composing micro-frontends at CDN edge.

Universal Module Federation: Sharing code between web, mobile, and desktop.

Conclusion

Micro-frontends solve real problems for teams building large applications. They enable autonomy, speed up deployments, and reduce coordination overhead.

But they're not free. The complexity trade-off only makes sense at scale.

If you're facing slow builds, deployment bottlenecks, and team coordination issues, micro-frontends might be your solution.

Start small. Extract one module. Learn. Then scale.

Your future self (and your teams) will thank you.