Frontend

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.

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

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

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.

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

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 Shellconst 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 produtosconst 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 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 — Shellimport { 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    }  }}

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-frontendsinterface 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 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());  }}

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 inteiroimport { MaterialModule } from './material.module'; // tudo junto// ✅ Faça assim — só o que o micro-frontend precisaimport { 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.

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.