A microfronted architecture is based on the core principle of isolation of teams and codebases. This provides the benefit of autonomy, but it also brings many challenges. The main challenge is to establish communication between these standalone microfrontends when needed. A user action in one microfrontend would need to trigger a state change or an action in another.

This guide gives a complete detail about the most effective strategies for facilitating communication between isolated Angular micro frontends, complete with practical examples.

The Challenge: Maintaining Autonomy While Enabling Interaction

The goal is to allow communication without creating tight coupling. If one micro front end directly calls a function or service in another, you lose the key benefit of independent deployment and development. The communication mechanism should be a well-defined, public API contract, not a deep-reaching internal dependency.

Today we will discuss the three primary strategies that have their own trade-offs. Here they are:

  1. Browser-Native Custom Events: A simple, framework-agnostic approach using the browser’s own event system.
  2. A Shell-Provided Shared Service (or “Event Bus”): A more structured, Angular-native approach where the host application provides a centralized communication channel.
  3. Shared State Management (via the Shell): The most robust solution for managing complex, shared application state across multiple micro frontends.

Comparing Micro Frontend Communication Strategies in Angular

Strategy 1: Browser-Native Custom Events

This is often the simplest and most decoupled way to communicate. It leverages the browser’s built-in EventTarget.dispatchEvent() and window.addEventListener() methods. One micro frontend dispatches a custom event on the window object, and any other micro frontend (or the shell) can listen for it.

Practical Example: Using Custom Events

Let’s imagine a scenario where a “profile” micro frontend (mfe1) updates the user’s name, and a “header” micro frontend (mfe2) needs to display it.Sender (
This component has an input field and a button. On click, it dispatches an event.

// mfe1/src/app/profile/profile.component.ts
import { Component } from '@angular/core';
@Component({ selector: 'app-profile', template: ` <h3>Profile Micro Frontend</h3> <input #nameInput placeholder="Enter your name" /> <button (click)="updateUserName(nameInput.value)">Update Name</button> `,
})
export class ProfileComponent { updateUserName(name: string): void { if (!name) return; // 1. Create the CustomEvent with a unique name and a detail payload const event = new CustomEvent('userNameUpdated', { detail: { userName: name }, }); // 2. Dispatch the event on the global window object window.dispatchEvent(event); console.log('MFE1: Dispatched userNameUpdated event.'); }
}

Receiver (
This component listens for the event and updates its view.

// mfe2/src/app/header/header.component.ts
import { Component, OnInit, OnDestroy, ChangeDetectorRef } from '@angular/core';
@Component({ selector: 'app-header', template: ` <h2>Header Micro Frontend</h2> <p>Welcome, {{ userName }}!</p> `,
})
export class HeaderComponent implements OnInit, OnDestroy { userName = 'Guest'; // Define the event handler as a class property to easily add/remove the listener private userNameUpdateHandler = (event: Event) => { // We must cast the event to a CustomEvent to access the detail payload const customEvent = event as CustomEvent; this.userName = customEvent.detail.userName; console.log('MFE2: Caught userNameUpdated event and updated name.'); this.cdr.detectChanges(); // Trigger change detection manually if needed }; constructor(private cdr: ChangeDetectorRef) {} ngOnInit(): void { // 3. Add the event listener when the component initializes window.addEventListener('userNameUpdated', this.userNameUpdateHandler); } ngOnDestroy(): void { // 4. IMPORTANT: Clean up the listener to prevent memory leaks window.removeEventListener('userNameUpdated', this.userNameUpdateHandler); }
}

Strategy 2: A Shell-Provided Shared Service

For more structured, type-safe communication, the shell (or host) application can provide a shared service that acts as an event bus or a message broker. This service is made available to all micro frontends through Module Federation’s dependency sharing mechanism.

Practical Example: Using a Shared Service

1. Create the Service and Public API in the Shell (

First, create a “public API” file that defines the types and abstract classes. This is what you’ll share with the remotes to avoid a hard dependency on the shell’s entire codebase.

// shell/src/app/communication/public-api.ts
import { InjectionToken } from '@angular/core';
import { Observable } from 'rxjs';
// Define the shape of the communication service
export abstract class CommunicationService { abstract userName$: Observable<string>; abstract setUserName(name: string): void;
}
// Create an InjectionToken for providing the service
export const COMMUNICATION_SERVICE_TOKEN = new InjectionToken<CommunicationService>('CommunicationService');
Now, create the concrete implementation of this service in the shell.
// shell/src/app/communication/communication.service.ts
import { Injectable } from '@angular/core';
import { BehaviorSubject } from 'rxjs';
import { CommunicationService } from './public-api';
@Injectable({ providedIn: 'root',
})
export class AppCommunicationService extends CommunicationService { // Use a BehaviorSubject to provide the last value to new subscribers private userNameSubject = new BehaviorSubject<string>('Guest'); userName$ = this.userNameSubject.asObservable(); setUserName(name: string): void { this.userNameSubject.next(name); }
}
In your app.module.ts in the shell, provide the concrete service using the token.
// shell/src/app/app.module.ts
import { AppCommunicationService } from './communication/communication.service';
import { COMMUNICATION_SERVICE_TOKEN, CommunicationService } from './communication/public-api';
// ... other imports
@NgModule({ // ... providers: [ { provide: COMMUNICATION_SERVICE_TOKEN, useClass: AppCommunicationService }, // A trick to ensure the service is instantiated even if not used by the shell { provide: CommunicationService, useExisting: COMMUNICATION_SERVICE_TOKEN }, ], // ...
})
export class AppModule {}

2. Configure Module Federation in the Shell

The shell needs to share the service instance and the public API file.

// shell/webpack.config.js
const { share, withModuleFederationPlugin } = require('@angular-architects/module-federation/webpack');
module.exports = withModuleFederationPlugin({ // ... remotes config shared: share({ // Share Angular dependencies... '@angular/core': { singleton: true, strictVersion: true, requiredVersion: 'auto' }, // ... // Share the public API file so remotes can import the type './projects/shell/src/app/communication/public-api.ts': { singleton: true, strictVersion: true }, // CRITICAL: Share the service *instance* from the shell's DI container '@app/communication': { singleton: true, strictVersion: false, // Version isn't as critical here import: './projects/shell/src/app/communication/public-api.ts', // What remotes import provider: COMMUNICATION_SERVICE_TOKEN, // The token to resolve the service instance }, }),
});

3. Configure and Use in the Micro Frontends

The remote MFEs just need to configure their webpack to consume the shared dependency.

// mfe1/webpack.config.js
const { shareAll } = require('@angular-architects/module-federation/webpack');
module.exports = { // ... shared: { ...shareAll({ singleton: true, strictVersion: true, requiredVersion: 'auto' }), },
};

Sender (

// mfe1/src/app/profile/profile.component.ts
import { Component, Inject } from '@angular/core';
import { CommunicationService } from '../../../../shell/src/app/communication/public-api'; // Adjust path
@Component({ selector: 'app-profile', template: `...`, // same template as before
})
export class ProfileComponent { // Inject the service using its abstract class as the type constructor(private communicationService: CommunicationService) {} updateUserName(name: string): void { if (!name) return; this.communicationService.setUserName(name); }
}

Receiver (

// mfe2/src/app/header/header.component.ts
import { Component, Inject } from '@angular/core';
import { Observable } from 'rxjs';
import { CommunicationService } from '../../../../shell/src/app/communication/public-api'; // Adjust path
@Component({ selector: 'app-header', template: ` <h2>Header Micro Frontend</h2> <p>Welcome, {{ userName$ | async }}!</p> `,
})
export class HeaderComponent { userName$: Observable<string>; constructor(private communicationService: CommunicationService) { this.userName$ = this.communicationService.userName$; }
}

Strategy 3: Shared State Management (via the Shell)

When applications require complex state sharing—like shopping cart data, user login state, or feature flags—a dedicated state management solution like NgRx is the way to go. This setup gives all MFEs access to a global store controlled by the shell.

How It Works:

  1. The shell sets up NgRx store with reducers and effects.
  2. Only the public-facing actions and selectors are shared with MFEs, not the internal store structure.
  3. The store instance is shared using Module Federation.
  4. MFEs dispatch actions and select state using the shared API.

Pros:

  • All shared data is kept in one safe, easy-to-find place.
  • Works well even for big and complicated apps.
  • Helps you understand what changed and why when you are building or fixing the app.

Cons:

  • Setting it up can take more time and work.
  • You might need to write extra code, especially if you have a lot of data to manage.
  • Poor organization can result in the app getting slow or hard to understand.

Use Case:

Imagine an online store app made of many small parts called micro frontends, like a login part, product list, and shopping cart. All these parts need to know if someone is logged in and what items are in the shopping cart. NgRx is like a smart helper that keeps all this shared information in one central place called the store.

When one part changes something—like logging a user in or adding an item to the cart—NgRx updates the central store. Then, all other parts get this update automatically. This means the parts don’t have to talk to each other directly or mix up different versions of information. They stay independent but share the same, up-to-date data easily, making the whole app work smoothly without confusion.

Choosing the Right Strategy

StrategyComplexityCouplingType SafetyBest For
Browser-Native Custom EventsLowVery LooseNoQuick integrations, basic events
Shell-Provided Shared ServiceMediumModerateYesStructured event handling, RxJS streams
Shared State Management (NgRx)HighModerateYesComplex app-wide shared state

Final Thoughts

The best approach depends on your application’s scale, complexity, and how comfortable your team is with different patterns. If you need fast and simple communication, custom events will get the job done.

For growing apps with clear API contracts, a shared Angular service offers structure without sacrificing too much independence. This is where it helps to hire Angular developers who know how to balance modular design with maintainability. And if your app juggles a lot of shared state, a centralized store like NgRx is the right tool, just be ready to manage the complexity it brings.