Client-Side UI Composition splits one large single-page application into several smaller front-end applications ("micro-frontends") that are built, tested, and deployed independently by different teams.
Intro
Client-Side UI Composition splits one large single-page application into several smaller front-end applications (“micro-frontends”) that are built, tested, and deployed independently by different teams. A thin shell (host) application runs in the browser and loads these pieces at runtime, either as JavaScript modules (Native Federation / Module Federation), as Web Components, or as iframes. The result is that the Claims team, the Policy team, and the Billing team can each ship their part of the UI on their own schedule, instead of queuing behind one shared front-end pipeline.
Why we need this
Backend teams moved to microservices to gain independent delivery, but many organisations still ship one Angular application that every team edits. The front-end becomes the last monolith.
Business reasons:
- Feature teams (Claims, Policy, Billing) are blocked by a single release train. A billing fix waits for a claims feature that is not ready.
- Time-to-market suffers because every release must regress-test the entire UI.
- Ownership is unclear: nobody owns “the whole front-end”, so quality decays.
Technical reasons:
- One repo and one CI pipeline means long builds (10+ minutes for a large Angular workspace) and constant merge conflicts in shared files such as
app.routes.ts, the root module, and global styles. - Upgrading Angular, a UI library, or a state-management library becomes a “big bang” that touches every team.
- A single bundle grows without limit, hurting load time.
Micro-frontends give each vertical slice (UI + BFF + service + data) one owning team, matching the Service per Team pattern (Day 4) all the way to the browser.
What problem it solves
Problem (from the topic list): A single front-end pipeline bottlenecks multiple teams. Resolution: The browser loads independently deployed micro-apps or web components.
Concrete example in our insurance claims system. Three teams own Claims Intake, Policy Lookup, and Payments. All three edit one Angular repo:
- Payments team finishes a fix on Monday but cannot release, because the Claims team’s half-finished “photo upload” feature is merged on
main. - The release is frozen for a shared 2-week regression cycle.
- A CSS change in the shared
styles.scssbreaks the Policy screen and nobody notices until QA. - Angular upgrade needs all three teams to stop feature work for a sprint.
Without the pattern, delivery speed is capped by the slowest team and the largest blast radius of any change.
When it is needed (and when it is NOT)
Good fit:
- 3 or more teams (roughly 15+ front-end developers) working on one product UI, with real release-coordination pain.
- Clear business-domain boundaries in the UI (claims, policy, billing), each already backed by its own services.
- Long-lived product where independent upgrade paths matter.
- Gradual modernisation: wrapping a legacy AngularJS or older Angular screen next to new screens.
Not a good fit (overkill):
- One team, or fewer than about 10 front-end developers. A well-structured Angular monorepo with lazy-loaded routes and Nx module boundaries gives most of the benefit at a fraction of the cost.
- Screens that share a lot of tightly coupled state (for example, a wizard where every step edits the same form model).
- Strict performance budgets on low-end devices where extra runtime, duplicate libraries, and extra network calls are unaffordable.
- Teams without DevOps maturity to run many pipelines and manage version compatibility.
Rule of thumb: adopt micro-frontends to solve an organisational scaling problem, never a purely technical one.
How to identify the problem (key signals)
- Release queue: Teams say “we are waiting for the next front-end release” even though their backend service is ready. Lead time from merged to production is measured in weeks.
- Merge conflicts in shared files: Frequent conflicts in
app.routes.ts,app.config.ts, the global stylesheet, or shared state stores. - Slow pipeline: CI build plus tests for the front-end take 15+ minutes and every team’s pull request runs the whole suite.
- Cross-team regressions: A change by team A breaks a screen owned by team B, discovered late by QA or by users.
- Frozen upgrades: Angular or library upgrades are postponed for many months because “everyone must move together”.
- Bundle bloat: Initial JavaScript bundle keeps growing; lazy loading is inconsistent because no one owns bundle budgets.
- Ownership ambiguity: Bug tickets bounce between teams because the code area has no clear owner.
Flow Diagram
The shell loads independently deployed micro-frontends at runtime through a manifest.
flowchart LR BR["Browser"] --> SH["Angular shell: auth, layout, routing"] SH --> MAN["federation.manifest.json"] MAN --> R1["mfe-claims remoteEntry.json"] MAN --> R2["mfe-policy remoteEntry.json"] R1 --> B1["Claims BFF"] --> D1[("Azure SQL")] R2 --> B2["Policy BFF"] --> D2[("PostgreSQL")] R1 -. "load fails" .-> FB["Fallback route"]Level 1: Beginner
Concept and analogy
Think of a shopping mall. The mall building (the shell) provides the corridors, lighting, and security. Each shop (a micro-frontend) is run by a different business. A shop can renovate or change its stock without asking the other shops, as long as it follows the mall’s rules (entrances, signage, fire exits).
In our system: the shell shows the header, login, and navigation. The “Claims” shop, the “Policy” shop, and the “Payments” shop are separate apps loaded on demand.
Minimal working example (plain Web Components, no framework)
This is the simplest possible client-side composition: the shell loads a script that registers a custom element, then places that element on the page.
<!doctype html><html> <body> <header>Claims Portal</header> <main> <!-- the shell only knows the tag name and its attributes --> <claim-status claim-id="CLM-1001"></claim-status> </main>
<!-- script is deployed and versioned by the Claims team --> <script type="module" src="https://claims.example.com/claim-status.js"></script> </body></html>class ClaimStatus extends HTMLElement { connectedCallback() { const id = this.getAttribute('claim-id'); this.attachShadow({ mode: 'open' }).innerHTML = `<p>Claim <strong>${id}</strong>: Under review</p>`; }}customElements.define('claim-status', ClaimStatus);Key ideas for beginners: (1) the contract is a tag name plus attributes and events; (2) Shadow DOM keeps styles from leaking; (3) the Claims team can redeploy claim-status.js without touching the shell.
Level 2: Intermediate
Real .NET + Angular + database setup
Target: current .NET LTS (.NET 10) and a current Angular major version (Angular 21 or later; check the current major before starting). Angular’s CLI now uses the esbuild-based application builder, so the recommended module-federation approach is Native Federation (@angular-architects/native-federation), which is built on browser-standard ES modules and import maps rather than Webpack.
Architecture:
shellAngular app: navigation, authentication, layout, routing.mfe-claims,mfe-policy: separate Angular apps, each in its own repo and pipeline.- Each micro-frontend calls its own backend (a BFF or the service directly through the API Gateway), each with its own database (SQL Server for Claims, PostgreSQL for Policy).
Remote (mfe-claims) federation.config.js:
const { withNativeFederation, shareAll } = require('@angular-architects/native-federation/config');
module.exports = withNativeFederation({ name: 'mfeClaims', exposes: { './Routes': './src/app/claims.routes.ts', }, shared: { ...shareAll({ singleton: true, strictVersion: true, requiredVersion: 'auto' }), },});Remote routes (claims.routes.ts):
import { Routes } from '@angular/router';import { ClaimListComponent } from './claim-list.component';import { ClaimDetailComponent } from './claim-detail.component';
export const CLAIMS_ROUTES: Routes = [ { path: '', component: ClaimListComponent }, { path: ':id', component: ClaimDetailComponent },];Shell routes, loading the remote lazily at runtime:
import { Routes } from '@angular/router';import { loadRemoteModule } from '@angular-architects/native-federation';
export const routes: Routes = [ { path: 'claims', loadChildren: () => loadRemoteModule('mfeClaims', './Routes').then(m => m.CLAIMS_ROUTES), }, { path: 'policies', loadChildren: () => loadRemoteModule('mfePolicy', './Routes').then(m => m.POLICY_ROUTES), },];Shell federation.manifest.json, the runtime registry (change a URL without rebuilding the shell):
{ "mfeClaims": "https://claims.portal.example.com/remoteEntry.json", "mfePolicy": "https://policy.portal.example.com/remoteEntry.json"}Bootstrap in the shell (main.ts):
import { initFederation } from '@angular-architects/native-federation';
initFederation('federation.manifest.json') .catch(err => console.error(err)) .then(() => import('./bootstrap')) .catch(err => console.error(err));Backend contract per micro-frontend (.NET minimal API in Claims service):
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddCors(o => o.AddPolicy("portal", p => p .WithOrigins("https://portal.example.com", "https://claims.portal.example.com") .AllowAnyHeader() .AllowAnyMethod()));
builder.Services.AddAuthentication("Bearer").AddJwtBearer(); // configured via appsettings
var app = builder.Build();app.UseCors("portal");app.UseAuthentication();app.UseAuthorization();
app.MapGet("/api/claims/{id}", async (string id, ClaimsDbContext db) => await db.Claims.FindAsync(id) is { } claim ? Results.Ok(claim) : Results.NotFound()) .RequireAuthorization();
app.Run();Note: ClaimsDbContext is the team’s own EF Core context over its own database. Nothing in the micro-frontend reaches into another team’s tables.
Cross-micro-frontend communication: keep it minimal. Prefer URL/route parameters and browser events over shared state.
// Publish from Claims MFE (no dependency on the Payments MFE)window.dispatchEvent(new CustomEvent('claim:approved', { detail: { claimId: 'CLM-1001', amount: 4200 },}));
// Subscribe in Payments MFEwindow.addEventListener('claim:approved', (e: Event) => { const { claimId } = (e as CustomEvent).detail; // refresh the pending-payments list});Level 3: Advanced
Performance
- Share heavy libraries (Angular core, RxJS) as singletons so they load once. Without sharing, each micro-frontend ships its own copy and page weight multiplies.
- Enforce bundle budgets per micro-frontend in CI (Angular
budgetsinangular.json). - Prefetch remote entries on hover or idle for likely-next routes.
- Cache remote assets with content-hashed file names and long
Cache-Control, but serveremoteEntry.jsonand the manifest with short or no-cache so new releases are picked up.
Scalability
- Independent CI/CD: each MFE has its own pipeline, tests, and release cadence.
- Consumer-side contract tests (Day 51) protect the API each MFE calls; a UI contract (exposed module names, custom-element attributes, event names) needs its own versioning.
Security
- Authentication lives in the shell. The shell obtains the token (OIDC, for example Microsoft Entra ID via MSAL) and MFEs receive it via a shared service or a sanctioned API, never by each MFE running its own login.
- Micro-frontends load executable code from other origins. Lock down CSP (
script-srcallow-list), use Subresource Integrity where practical, and only load remotes from origins you control. - Treat every remote as trusted code with full DOM access to the shell. If a third party’s UI must be embedded, use an iframe with
sandboxinstead. - Validate authorisation on the server; hiding a menu item in the shell is not security.
Failure modes
- A remote is down or fails to load: the whole shell route may crash. Wrap
loadRemoteModulein error handling and show a fallback (Day 29). - Version skew: shell expects
strictVersionAngular 21.x but a remote was built with 22.x. Federation reports a mismatch at runtime, not at build time. - CSS collisions between teams.
- Duplicate singletons (two Angular runtimes) causing DI errors such as “NullInjectorError” or broken change detection.
// Defensive loading with fallback{ path: 'claims', loadChildren: () => loadRemoteModule('mfeClaims', './Routes') .then(m => m.CLAIMS_ROUTES) .catch(() => import('./fallback/unavailable.routes').then(m => m.UNAVAILABLE_ROUTES)),}Common mistakes
- Splitting by technical layer (a “buttons” MFE, a “forms” MFE) instead of by business domain.
- Sharing too much state through a global store, recreating a distributed monolith in the browser.
- No design system, so every MFE looks different. Publish a shared component/design-token library.
- Making MFEs too small (one per page), so the operational overhead exceeds the benefit.
- No shell-level observability: errors in remotes are not tagged with the MFE name and version.
Level 4: Expert and Architect view
Composition options compared
| Approach | How it works | Strengths | Weaknesses | Best for |
|---|---|---|---|---|
| Native Federation (ESM + import maps) | Runtime loading of ES modules, framework-agnostic build tooling (esbuild/Vite) | Works with Angular’s modern builder, standards-based, shared dependency negotiation | Extra config; version-skew must be governed | Angular-centred enterprise portals |
| Webpack Module Federation | Webpack runtime container sharing modules | Mature, large community | Tied to Webpack; Angular’s default builder moved to esbuild | Existing Webpack-based estates |
| Web Components (custom elements) | Each MFE registers a custom element | Framework-agnostic, strong style isolation (Shadow DOM) | Weaker routing and DI integration, duplicate frameworks if not shared | Mixed-framework or widget-style embedding |
| iframes | Each MFE is its own document | Strongest isolation (security, CSS, JS) | Poor UX for routing, sizing, deep links, accessibility; performance cost | Untrusted or third-party UI; legacy apps |
| Build-time composition (npm packages) | MFEs published as packages, shell rebuilds | Simple, type-safe | Not independently deployable; the shell must redeploy | Not true micro-frontends |
| Server-Side Fragment Composition (Day 52) | Server assembles HTML | SEO, fast first paint | Less interactive; server-side coupling | Content-heavy sites |
| Modular monolith SPA (Nx, lazy routes) | One repo, enforced module boundaries | Simple ops, one release | Single pipeline and release | Teams under ~10 front-end devs |
Patterns it combines with
- Backends for Frontends (Day 20): each MFE (or shell) gets its own BFF.
- API Gateway (Day 19): single origin for all MFE assets and APIs to avoid CORS complexity.
- Service per Team (Day 4) and Decompose by Business Capability (Day 1): UI slices follow the same boundaries.
- Access Token (Day 38): the shell owns authentication and token distribution.
- Consumer-Driven Contract Test (Day 51): protects MFE-to-API and MFE-to-shell contracts.
- Circuit Breaker and Fallback (Days 26, 29): graceful degradation when a remote fails.
- Strangler Fig (Day 49): migrate a legacy UI screen by screen behind the shell.
ADR (architecture review format)
ADR-053: Adopt client-side micro-frontends for the Claims Portal
- Status: Proposed
- Context: Four teams (Intake, Policy, Payments, Reporting) share one Angular repository. Median time from merge to production is 18 days because of a shared release train. Angular upgrades have been deferred twice because of cross-team coordination. Each team already owns its own backend service and database.
- Decision: Adopt a shell + micro-frontends architecture using Native Federation on the current Angular major version. The shell owns authentication, layout, and navigation; each domain team owns one remote, one pipeline, and one BFF. Shared code is limited to a versioned design-system package and a small “shell contracts” package (events, route names, token service interface).
- Alternatives considered: (a) Nx modular monolith with enforced boundaries, rejected because it keeps one release pipeline; (b) iframes, rejected for UX and accessibility limits; (c) Web Components only, rejected because of weaker Angular routing and DI integration.
- Consequences (positive): independent releases per team, smaller pipelines, isolated blast radius, staged framework upgrades.
- Consequences (negative): higher operational and governance cost, risk of UI inconsistency, more runtime complexity, harder end-to-end testing, need for platform-team ownership of the shell.
- Exit criteria and review: Re-evaluate after two quarters. Success = lead time under 3 days per team and no cross-team release blockers.
Azure implementation
Services that implement or support the pattern
- Azure Static Web Apps (SWA): host the shell and each micro-frontend’s static build output. Built-in global distribution, GitHub Actions or Azure DevOps deployment, staging environments for pull requests, custom domains and managed certificates. Plans are Free and Standard; the Standard plan is needed for features such as custom authentication providers, larger app and storage limits, and SLA. Verify current limits and the per-app price on the Azure pricing page before budgeting.
- Azure Storage static website + Azure Front Door (or Azure CDN): an alternative for hosting each remote’s static files at low cost with a global edge, custom routing, WAF, and caching rules.
- Azure Front Door: path-based routing (
/claims/*to the claims origin,/policy/*to the policy origin) so the browser sees a single origin, avoiding CORS. Add WAF policies. Front Door tiers are Standard and Premium; Premium adds Private Link origins and advanced security. Confirm current pricing. - Azure API Management (or YARP on Azure Container Apps) as the API Gateway: one API surface for all MFEs, with JWT validation and rate limiting.
- Azure Container Apps / App Service: host the .NET BFFs and backend services.
- Microsoft Entra ID (with MSAL Angular): single sign-on in the shell.
- Azure Monitor / Application Insights: front-end telemetry via the Application Insights JavaScript SDK.
- Azure Key Vault and App Configuration: store non-public configuration and feature flags; the remote manifest can be served from App Configuration through a small endpoint.
How to configure
- One Azure resource per micro-frontend (an SWA or a storage container) with its own pipeline; shell has its own.
- Front Door routes:
/to the shell origin,/mfe/claims/*to the claims origin,/mfe/policy/*to the policy origin,/api/*to API Management. - Headers:
Cache-Control: public, max-age=31536000, immutablefor hashed files,no-cacheforremoteEntry.jsonandfederation.manifest.json. - CSP header at Front Door or SWA
staticwebapp.config.jsonglobalHeaders. - Application Insights: initialise once in the shell; set a cloud role name or custom dimension
mfeNameandmfeVersionfor every telemetry item from a remote.
{ "globalHeaders": { "Content-Security-Policy": "default-src 'self'; script-src 'self' https://claims.portal.example.com https://policy.portal.example.com; connect-src 'self' https://api.example.com https://login.microsoftonline.com", "X-Content-Type-Options": "nosniff" }, "navigationFallback": { "rewrite": "/index.html" }}Pricing and tier considerations
- SWA Free is suitable for learning and prototypes; production should use the Standard plan. Each micro-frontend deployed as its own SWA is billed per app, so many small remotes add up. Storage static website plus Front Door can be cheaper at scale, but adds Front Door base fees.
- Front Door Standard is enough for routing and caching; use Premium if you need WAF managed rulesets with bot protection or Private Link to origins.
- Application Insights bills on ingested data; sample front-end telemetry.
- Always confirm current prices for your region on the Azure pricing calculator.
Reference architecture (text)
Browser → Azure Front Door (WAF, TLS, routing, caching) → three kinds of origins: (1) shell static site, (2) one static site per micro-frontend, (3) API Management → per-domain BFFs on Azure Container Apps → own databases (Azure SQL for Claims, Azure Database for PostgreSQL for Policy). The shell authenticates the user with Entra ID and passes the access token to the remotes through a shared service. Application Insights collects browser and server telemetry correlated by trace ID. Azure DevOps or GitHub Actions run one pipeline per repo; Front Door and SWA staging slots allow canary releases of a single micro-frontend.
Teaching guide for my team
Explain to a beginner in 2 minutes
“Imagine our claims website is a mall. The mall has a main entrance, a directory, and security; that is the shell. Each shop inside, Claims, Policy, Payments, is run by a different team. When you click ‘Claims’, the mall fetches the Claims shop from its own server and shows it. The Claims team can redecorate any time without asking Payments. The price we pay is that shops must agree on some rules: the same look, one login, and a few agreed ways to talk to each other.”
Explain to an intermediate developer in 5 minutes
Start with the problem: one Angular repo, one pipeline, four teams, and release queues. Then show the three pieces: shell, remotes, manifest. The shell’s router lazy-loads a remote using loadRemoteModule; the remote exposes a routes file; a federation.manifest.json tells the shell where each remote lives at runtime. Explain shared singletons (Angular, RxJS) and why version mismatches break at runtime. Cover authentication (shell owns the token), communication (URL and events, not shared stores), and failure handling (fallback route). Finish with the trade-off: you gain team autonomy and lose simplicity, so use it only when the organisation, not the code, is the bottleneck.
Hands-on exercise
Build a shell and one remote with Native Federation.
- Create two Angular workspaces:
shellandmfe-claims. - Run
ng add @angular-architects/native-federationin each (--type dynamic-hostfor the shell,--type remotefor the remote, with different ports). - Expose the claims routes from the remote and load them from the shell at
/claims. - Add a
federation.manifest.jsonin the shell pointing to the remote’sremoteEntry.json. - Change the remote’s heading text and rebuild only the remote.
Expected outcome: after refreshing the browser, the shell shows the changed heading without rebuilding or redeploying the shell. Then stop the remote and confirm the shell shows the fallback page instead of crashing.
Interview-style questions
- Q: What is the difference between a micro-frontend and a lazy-loaded Angular route? A: A lazy route is split at build time inside one application and one release. A micro-frontend is built and deployed independently and loaded at runtime, so it has its own pipeline and release cadence.
- Q: Why should shared libraries such as Angular be singletons in federation? A: Two Angular runtimes on one page cause duplicate dependency-injection containers, larger downloads, and subtle bugs. A singleton ensures one instance is shared;
strictVersionfails loudly on incompatibility. - Q: How should micro-frontends communicate? A: Loosely: through URL parameters, custom browser events, or a small shared contract package. Avoid a shared global store, which recreates coupling and a distributed monolith.
Mastery checklist
- I can explain why micro-frontends solve an organisational scaling problem and name two situations where they should not be used.
- I can identify at least five signals of a front-end monolith bottleneck in a real project.
- I can build a shell and remote with Native Federation and load the remote through a runtime manifest.
- I can configure shared singleton dependencies and explain what happens on a version mismatch.
- I can design authentication so the shell owns login and remotes receive the token safely.
- I can implement a fallback for a failed remote and tag telemetry with MFE name and version.
- I can compare Native Federation, Module Federation, Web Components, iframes, and a modular monolith in an architecture review.
- I can write an ADR that justifies (or rejects) micro-frontends with measurable exit criteria.
Key takeaway
Micro-frontends give each team its own deployable slice of the UI, but they trade simplicity for autonomy. Adopt them when many teams are blocked by one front-end pipeline, and keep the shell thin, the shared surface small, and the boundaries aligned to business domains.
