Angular Micro-frontends: How to Scale Large Applications and Eliminate Monolithic Build Times with Module FederationFrontend

Frontend · 21 Jun 2026

Angular Micro-frontends: How to Scale Large Applications and Eliminate Monolithic Build Times with Module Federation

Discover how to break down large Angular monoliths using Module Federation to enable independent deployments and seamless team scaling.

Genildo Souza21 Jun 2026Read: 10 min

Angular Micro-frontends Architecture

  • Micro-frontends solve monolithic scaling issues by allowing independent development, deployment, and builds for different teams.

  • Module Federation enables Angular apps to share modules at runtime, allowing separate applications to function as a single cohesive unit.

  • The Shell application acts as an orchestrator that loads remote micro-frontends on demand via the Angular router.

  • Setting shared dependencies to singleton: true is critical to prevent code duplication and maintain a single instance of Angular.

  • Communication between independent micro-frontends should be managed through an event bus pattern to keep modules decoupled.

Your Angular app started small. About 15 components, two routes, an authentication module. The build took seconds. Merges were smooth. Life was good.

Six months later, the project has 200 components. Three squads working on the same repository. The ng build takes 4 minutes. Every merge becomes a diplomatic negotiation. And the worst part: when the product team breaks something in the checkout, the catalog team finds out on Friday night.

This isn't a bad code problem. It's an architecture problem. When three teams share the same build, the same deploy, and the same bundle — any change from one affects all the others.

Micro-frontends exist to solve exactly this.

What are micro-frontends (without the jargon)

If you've heard of microservices on the backend, micro-frontends are the same idea applied to the frontend: instead of one giant monolithic app, you split the application into smaller, independent pieces. Each piece has its own repository, its own build, and its own deploy.

In Angular, this means the product module can be a separate Angular app. The orders module, another. The authentication module, another. Each one developed, tested, and published by different teams — without stepping on anyone's toes.

A main application (the Shell) orchestrates everything: it loads the micro-frontends on demand, handles global routing, and ensures the end user sees a unique and cohesive experience — without realizing that underneath there are several independent apps.

The three pillars of a micro-frontend architecture

Shell + Remotes + Shared = micro-frontends that work as a single app for the end user.

🏠

Shell

A aplicação host que orquestra tudo. Cuida do roteamento global e carrega os micro-frontends dinamicamente.

📦

Remotes

Os micro-frontends independentes. Cada um é um app Angular completo, com seu próprio build e deploy.

🔗

Shared

Dependências compartilhadas entre todos — Angular core, RxJS, design system. Carregadas uma vez, usadas por todos.

Module Federation: the technology that makes it all work

The magic behind micro-frontends in Angular has a name: Module Federation. It's a Webpack 5 feature that allows different builds to share modules at runtime. In practical terms: the product app can import code from the orders app without the two having been compiled together.

Let's see how this is set up in practice. First, the Shell configuration — the app that will orchestrate everything:

javascript
// webpack.config.js — configuração do Shell
const ModuleFederationPlugin = require("@module-federation/webpack");

module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: "shell",
      remotes: {
        // Cada micro-frontend é registrado aqui
        "mfe-products": "mfeProducts@http://localhost:4201/remoteEntry.js",
        "mfe-orders": "mfeOrders@http://localhost:4202/remoteEntry.js"
      },
      shared: {
        // Dependências que todos compartilham — carregadas uma vez só
        "@angular/core": { singleton: true, strictVersion: true },
        "@angular/common": { singleton: true, strictVersion: true },
        "@angular/router": { singleton: true, strictVersion: true }
      }
    })
  ]
};

Now, the product micro-frontend configuration — the app that will be loaded remotely:

javascript
// webpack.config.js — configuração do micro-frontend de produtos
const ModuleFederationPlugin = require("@module-federation/webpack");

module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: "mfeProducts",
      filename: "remoteEntry.js", // o ponto de entrada que o Shell vai carregar
      exposes: {
        // O que esse micro-frontend disponibiliza pro mundo
        "./ProductsModule": "./src/app/products/products.module.ts"
      },
      shared: {
        "@angular/core": { singleton: true, strictVersion: true },
        "@angular/common": { singleton: true, strictVersion: true }
      }
    })
  ]
};

The singleton: true in shared dependencies is critical. Without this, each micro-frontend would load its own copy of Angular — duplicating code, breaking dependency injection, and bloating the bundle. With singleton: true, everyone shares the same instance.

The routing that connects everything

With Module Federation configured, the next step is to connect the micro-frontends to the Angular router. This is where it gets elegant: the Shell loads each micro-frontend on demand, when the user navigates to that route.

typescript
// app-routing.module.ts — Shell
import { loadRemoteModule } from '@angular-architects/module-federation';

const routes: Routes = [
  {
    path: 'products',
    loadChildren: () => loadRemoteModule({
      type: 'module',
      remoteEntry: 'http://localhost:4201/remoteEntry.js',
      exposedModule: './ProductsModule'
    }).then(m => m.ProductsModule)
  },
  {
    path: 'orders',
    loadChildren: () => loadRemoteModule({
      type: 'module',
      remoteEntry: 'http://localhost:4202/remoteEntry.js',
      exposedModule: './OrdersModule'
    }).then(m => m.OrdersModule)
  },
  { path: '', redirectTo: '/products', pathMatch: 'full' }
];

Notice: loadRemoteModule works like the loadChildren you already know from lazy loading — but instead of loading a local module, it fetches a module from another server. To the Angular Router, it's transparent. To the user, it's invisible.

Dynamic loading: don't load what the user doesn't need

Lazy routing already solves part of the performance problem. But in large apps, you need more control. A dynamic loading service prevents the same micro-frontend from being downloaded twice and handles network failures:

typescript
@Injectable({ providedIn: 'root' })
export class DynamicModuleService {
  private loadedModules = new Set<string>();

  async loadModule(
    remoteName: string,
    exposedModule: string,
    remoteEntry: string
  ) {
    const moduleKey = `${remoteName}-${exposedModule}`;

    if (this.loadedModules.has(moduleKey)) {
      return; // já carregou, não busca de novo
    }

    try {
      await loadRemoteModule({ type: 'module', remoteEntry, exposedModule });
      this.loadedModules.add(moduleKey);
    } catch (error) {
      console.error(`Falha ao carregar ${moduleKey}:`, error);
      throw error; // deixa o componente de fallback cuidar
    }
  }
}

Newsletter

Technology decoded, always. — A curated read on AI, product, and the culture behind the code. No noise, no hype.

Communication between micro-frontends

If each micro-frontend is independent, how do they talk to each other? The user logs in to the authentication module — how does the orders module find out?

The answer is a pattern the backend has been using for years: event bus. A central service that publishes and receives typed events. No micro-frontend knows about the other directly — they only know about the events.

typescript
// Tipos dos eventos — o contrato entre micro-frontends
interface EventPayloadMap {
  'USER_LOGGED_IN': { userId: string; role: string };
  'CART_UPDATED': { itemCount: number; total: number };
  'NAVIGATION_REQUESTED': { path: string; params?: any };
}

@Injectable({ providedIn: 'root' })
export class MicroFrontendEventBus {
  private bus = new Subject<{ type: string; payload: any; source: string }>();

  // Publicar evento — type-safe
  publish<T extends keyof EventPayloadMap>(
    type: T,
    payload: EventPayloadMap[T],
    source: string
  ): void {
    this.bus.next({ type, payload, source });
  }

  // Ouvir evento — type-safe
  on<T extends keyof EventPayloadMap>(
    eventType: T
  ): Observable<EventPayloadMap[T]> {
    return this.bus.pipe(
      filter(event => event.type === eventType),
      map(event => event.payload as EventPayloadMap[T])
    );
  }
}

The EventPayloadMap is the secret. It works like a contract: every micro-frontend knows which events exist and what the format of each one is. If someone changes the payload of CART_UPDATED, TypeScript warns everywhere that consumes this event. No runtime surprises.

The mistakes everyone makes (and how to avoid them)

Micro-frontends solve real problems, but create new ones if implemented carelessly. These are the three most common mistakes — and all three I've seen in production.

1. Not having a fallback when a micro-frontend fails

If the product micro-frontend server goes down, what does the user see? If the answer is "a white screen," you have a problem. Every remote micro-frontend needs a fallback component:

typescript
@Component({
  template: `
    <div class="error-fallback">
      <h3>Este módulo está temporariamente indisponível</h3>
      <p>Estamos trabalhando pra restaurar. Tente novamente em alguns minutos.</p>
      <button (click)="retry()">Tentar novamente</button>
    </div>
  `
})
export class MicroFrontendFallbackComponent {
  retry() {
    window.location.reload();
  }
}

2. Sharing global state between micro-frontends

The natural instinct is to create a global store that all micro-frontends access. Resist this instinct. Shared global state creates tight coupling — exactly what micro-frontends exist to eliminate. Each micro-frontend maintains its own state and communicates via events:

typescript
@Injectable()
export class BoundedContextService {
  // Cada micro-frontend mantém seu próprio estado
  private localState = new BehaviorSubject(initialState);

  // Comunica via eventos, nunca compartilha estado diretamente
  notifyDomainEvent(event: DomainEvent): void {
    this.eventBus.publish('DOMAIN_EVENT', {
      context: this.contextName,
      event: event,
      timestamp: Date.now()
    }, this.contextName);
  }
}

3. Memory leaks in dynamic loading

When a micro-frontend is loaded and then unloaded, it needs to clean up everything behind it — subscriptions, component references, timers. If it doesn't clean up, the app accumulates memory with every navigation:

typescript
@Injectable()
export class MicroFrontendLifecycleService implements OnDestroy {
  private subscriptions = new Map<string, Subscription>();
  private loadedComponents = new Map<string, ComponentRef<any>>();

  unloadMicroFrontend(name: string): void {
    // Destruir componente
    const component = this.loadedComponents.get(name);
    if (component) {
      component.destroy();
      this.loadedComponents.delete(name);
    }

    // Cancelar subscriptions
    const sub = this.subscriptions.get(name);
    if (sub) {
      sub.unsubscribe();
      this.subscriptions.delete(name);
    }
  }

  ngOnDestroy(): void {
    // Na destruição do serviço, limpa tudo
    this.loadedComponents.forEach(c => c.destroy());
    this.subscriptions.forEach(s => s.unsubscribe());
  }
}

Rule of thumb: if your app gets slower the more the user navigates between sections, there is likely a memory leak in the dynamic loading. Use Chrome DevTools → Memory → Heap Snapshot before and after navigating to identify the leak.

Performance: what to optimize first

Micro-frontends can improve or worsen your app's performance — it depends on how you configure dependency sharing and the loading strategy.

Import only what you use

typescript
// ❌ Evite — importar o Angular Material inteiro
import { MaterialModule } from './material.module'; // tudo junto

// ✅ Faça assim — só o que o micro-frontend precisa
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatInputModule } from '@angular/material/input';

@NgModule({
  imports: [MatButtonModule, MatCardModule, MatInputModule]
})
export class ProductsModule { }

Intelligent preloading

Not every micro-frontend should be loaded on demand. The product module in an e-commerce site will be accessed by 90% of users — it makes sense to preload it. The reports module? Only during business hours. You can create a preload strategy based on context:

typescript
@Injectable()
export class SmartPreloadingStrategy implements PreloadingStrategy {
  preload(route: Route, load: () => Observable<any>): Observable<any> {
    const priority = route.data?.['preload'];

    if (priority === 'high') return load(); // sempre pré-carrega
    if (priority === 'business-hours') {
      const hour = new Date().getHours();
      if (hour >= 9 && hour <= 17) return load();
    }

    return of(null); // carrega só quando o usuário navegar
  }
}

// Nas rotas:
const routes: Routes = [
  {
    path: 'products',
    data: { preload: 'high' }, // sempre pré-carrega
    loadChildren: () => loadRemoteModule({...})
  },
  {
    path: 'reports',
    data: { preload: 'business-hours' }, // só no horário comercial
    loadChildren: () => loadRemoteModule({...})
  }
];

When NOT to use micro-frontends

This is the most important section of the article.

Micro-frontends add real complexity: more repositories to maintain, more CI/CD pipelines to configure, more points of failure in production, more overhead of communication between modules. This complexity is only justified when the problem it solves is greater than the pain it creates.

Use micro-frontends when: multiple teams work on distinct domains of the same application and merge conflicts, build time, and deploy are delaying deliveries. Don't use when: the team is small, the app is manageable, and the real problem is code organization — which can be solved with lazy loading and good modularization, without needing Module Federation.

Migrating a monolith: where to start

If you've decided that micro-frontends make sense for your context, don't try to migrate everything at once. The approach that works is incremental: keep the legacy routes working while adding the new federated routes alongside them.

typescript
// Migração incremental — rotas legadas e federadas coexistem
@NgModule({
  imports: [RouterModule.forRoot([
    // Rota legada — ainda funciona normalmente
    {
      path: 'legacy-products',
      component: ProductsComponent
    },
    // Nova rota federada — micro-frontend independente
    {
      path: 'products',
      loadChildren: () => loadRemoteModule({
        type: 'module',
        remoteEntry: 'http://localhost:4201/remoteEntry.js',
        exposedModule: './ProductsModule'
      }).then(m => m.ProductsModule)
    }
  ])]
})
export class AppRoutingModule { }

When the product micro-frontend is stable in production, you remove the legacy route. One domain at a time. No big bang.

Takeaways from this article

  • Micro-frontends split a monolithic app into independent apps with their own build, deploy, and repository.

  • Module Federation (Webpack 5) allows separate builds to share modules at runtime — the Shell loads the Remotes on demand.

  • Communication via typed event bus — each micro-frontend knows the events, never the other micro-frontend directly.

  • Always implement fallbacks — if one micro-frontend goes down, the rest of the app should keep working.

  • Don't share global state — each micro-frontend maintains its state and communicates via events.

  • Clean up references and subscriptions when unloading micro-frontends — memory leaks are the most common silent bug.

  • Micro-frontends add real complexity. Only use them when multiple teams in distinct domains justify this complexity.

  • Migrate incrementally — legacy and federated routes coexist until the new micro-frontend is stable.

Angular

Want to master modern Angular?

Signals, incremental hydration, new control flow — Angular has changed. Explore the other articles in the series to understand what changed and how to apply it.