ARCHITECTURE · 11 MIN · JULY 2026
Native Federation in practice
I ran Angular 18, Angular 21, and React 19 in the same shell via Native Federation and hit four distinct failures before it worked. Here's the architectural pattern behind each fix, and what the version-isolation tax actually costs in bundle size.

I built a team dashboard split across four independently-served pieces — an Angular 21 shell, a React 19 remote, an Angular 21 remote, and an Angular 18 remote — all wired together with Native Federation. The question I wanted to answer was simple: where's the real boundary between "Native Federation is the right call" and "this is complexity you bought for nothing"?

The short answer: when remotes share your framework version, federation is nearly free. Cross that version line and you're paying a full extra runtime download on every first visit, plus a set of integration failures that don't appear in any getting-started docs — four in my case, each revealing a different architectural constraint.

What I built

The demo is a "team dashboard" with four independently-served parts:

  • Shell — Angular 21, layout and routing
  • ActivityFeed — Angular 21 remote, same version as the shell
  • Projects — React 19 remote, cross-framework
  • Notifications — Angular 18 remote, same framework, different major version

The version mix was deliberate. Three distinct scenarios to test: same-version Angular (the cheap path), cross-framework React (requires isolation), and cross-major Angular (the question of whether two major versions can share a runtime). The Notifications remote is where things got interesting.

How sharing works: the import map

Native Federation's core mechanism is an import map. At boot, the shell calls initFederation, which fetches each remote's manifest and populates a browser import map with all the packages marked as shared. When a remote requests a shared package at a compatible version, it gets the already-loaded copy from the map — no separate download.

The key property: shared packages are resolved by version compatibility, not by presence. The shell loads @angular/core@21 into the map; a remote declaring it as a shared singleton gets that copy. If a remote needs @angular/core@18, there's no compatible entry — the runtime falls back to bundling its own copy.

The shell and ActivityFeed both declare everything as shared:

shared: {
  ...shareAll({ singleton: true, strictVersion: true, requiredVersion: 'auto' }),
}

Angular 21's full runtime — @angular/core, @angular/common, @angular/platform-browser, rxjs — lands in the import map once on first load (~156 kB gzipped). Every Angular 21 remote added after that costs only its own component code. The platform is paid once; the features are pay-per-navigation.

The version-isolation tax

The Notifications remote uses shared: {} — nothing shared. Angular 18 and Angular 21 aren't version-compatible, so it has to bundle its own full runtime.

Remote First-visit cost (gzipped)
ActivityFeed (Angular 21, shared runtime) 1.1 kB
Notifications (Angular 18, isolated) 74 kB

That 73 kB gap is the version-isolation tax: a full Angular 18 runtime downloaded and parsed on every first visit to the Notifications route. It never gets cheaper. The React remote costs ~61 kB gzipped for the same reason — React's runtime isn't in the import map, so it ships with the remote.

These numbers aren't arguments against using federation across versions. They're the cost model. If you know the cost upfront, you can decide whether independent deployment is worth it.

Failure 1: the injection context problem (NG0203)

My first attempt at loading the Angular 18 Notifications component was identical to how I loaded the Angular 21 ActivityFeed: get the remote module, call createComponent(), done. This throws NG0203: inject() must be called from an injection context.

The root cause is architectural. When you call Angular 21's ViewContainerRef.createComponent(), it sets up an injection context using Angular 21's internal runtime objects. The Angular 18 component calls Angular 18's inject(), which looks for Angular 18's injection context marker — a different object, from a different copy of @angular/core. Finds nothing. Throws.

The fix is a pattern, not a configuration change: the mount-function contract. Instead of exposing a component class for the host to instantiate, the remote exposes a function that manages its own application lifecycle:

export async function mount(element: HTMLElement): Promise<{ unmount: () => void }> {
  const appRef = await bootstrapApplication(NotificationsComponent, appConfig);
  return { unmount: () => appRef.destroy() };
}

The host hands the remote an HTMLElement and gets back a cleanup handle. Angular 18 bootstraps its own application instance inside the container the shell provides. No cross-runtime component instantiation — each runtime stays in its own lane.

This is the same interface the React remote uses: mount(element) returns { unmount }. The mount-function contract is the universal interface for any remote that can't share its runtime with the host, regardless of whether the isolation is because of a different framework or a different major version of the same one.

Failure 2: Zone.js isn't loaded (NG0908)

Switching to mount functions got past NG0203. Next failure: NG0908: In this configuration Angular requires Zone.js.

The shell's polyfills only include es-module-shims. For Angular 21 remotes this is fine — they share the shell's already-running runtime. But the Angular 18 remote is now bootstrapping its own separate Angular application via bootstrapApplication, and Angular 18 checks for window.Zone at startup. It isn't there.

The fix: tell Angular 18 not to require Zone.js. Angular 18 has provideExperimentalZonelessChangeDetection() which switches change detection to rely on signals and explicit markForCheck() calls instead of Zone.js patching:

export const appConfig: ApplicationConfig = {
  providers: [provideExperimentalZonelessChangeDetection()],
};

One implication worth naming upfront: this isn't just a config flag, it's a reactivity migration. If an existing Angular 18 component relies on Zone.js-triggered change detection — setTimeout, HTTP responses, Promise resolutions — those updates will silently stop working. Signals have to replace them. For a component built for federation, that's fine. For retrofitting an existing app as a federated remote, it's a real migration before you can even start.

Failure 3: the silent boot failure

initFederation fetches all remote manifests at application boot, before anything renders. If a remote isn't reachable, NF logs a warning but the boot promise still resolves. The shell appears to start normally. The failure surfaces later: the user navigates to a route, loadRemoteModule throws "unknown remote," and there's no obvious connection to what failed at boot.

In my case the root cause was a name mismatch between the manifest and the loadRemoteModule call. But the symptom is identical regardless of cause — name mismatch, server down, slow CORS-blocked fetch — because the boot doesn't tell you what failed.

Two ways to design around this:

Fix the manifest and keep names consistent. loadRemoteModule({ remoteName: '...' }) works only if the remote was registered at boot. This is the idiomatic approach and keeps URL configuration centralized in the manifest.

Use the remoteEntry URL approach for optional or unreliable remotes:

const m = await loadRemoteModule({
  remoteEntry: 'http://localhost:3003/remoteEntry.json',
  exposedModule: './mount',
});

With remoteEntry, NF fetches and registers the remote on first navigation rather than at boot. A remote that was down at startup but recovered before the user navigates will load successfully — no page reload required. The tradeoff is that the URL moves from the manifest into each wrapper component.

Failure 4: npm workspace isolation

The Angular 18 remote is intentionally excluded from the npm workspace:

"workspaces": ["remote-angular", "remote-react", "shell"]

remote-angular-v18 is absent. It has its own node_modules/ and its own lock file.

npm workspaces hoist shared dependencies to the root node_modules. If Angular 18 and Angular 21 were in the same workspace, npm would try to reconcile them — and either the wrong version would win, or the install would fail. The only safe option is complete npm isolation, which means dependencies aren't installed by a root npm install and build defaults from the newer Angular version don't apply.

Both of those bit me. The install is handled by a postinstall hook in the root package.json. The build config issue was more subtle: @angular/build:application in Angular 18 requires explicit outputPath and index fields in angular.json that Angular 21 infers automatically. Small detail, but the build fails silently until you add them.

When it pays off

Same-version Angular remotes are cheap. ~156 kB gzipped shared runtime paid once on first load, then ~1–3 kB per remote on navigation. If multiple teams ship Angular 21 modules independently, federation's runtime cost is essentially zero. Independent deployment is the payoff.

Cross-version Angular remotes are expensive: full isolated runtime, the mount-function pattern, a zoneless migration, npm workspace isolation, and version-specific build config. Four costs that don't exist for same-version remotes.

The break-even question is whether you actually need a different version in the same shell. If you're incrementally migrating a large Angular app, the answer might be yes — Angular 18 and Angular 21 can coexist while migration proceeds. For a greenfield system with a free choice, standardizing on one version eliminates all four failure modes above.

Cross-framework (React in an Angular shell) sits between the two. The mount-function pattern is required, but there's no version negotiation problem — React doesn't compete with Angular in the import map. The cost is the framework boundary itself, not the version delta.

Native Federation works well when remotes share a framework version. Every version or framework boundary adds a full isolated runtime plus the operational overhead of managing that isolation.

Whether that overhead is worth paying depends on whether independent deployment across those boundaries is an actual requirement or a hypothetical one.

CI/CD for independent deployments

The repository has four separate GitHub Actions workflows, one per deployable unit. Each is triggered by a path filter:

on:
  push:
    branches: [main]
    paths:
      - 'remote-angular-v18/**'

A commit touching only remote-angular-v18/ triggers the Notifications pipeline. The shell doesn't rebuild. This is independent deployability made concrete: the deployment unit matches the ownership unit.

A few structural decisions made this work cleanly on a shared GitHub Pages branch:

  • keep_files: true on every deployment — without it, each deploy would wipe the branch and only that remote's files would survive.
  • cancel-in-progress: false on a shared concurrency group — all four workflows write to the same branch, so concurrent runs need to queue, not cancel.
  • CI injects the production manifest — the checked-in federation.manifest.json has localhost URLs for local dev. The shell's workflow overwrites it with real URLs before ng build runs. Environment-specific config stays out of version control; local dev works unchanged.

Remote-down behavior

The silent boot failure has a concrete UX consequence worth designing around. When loadRemoteModule({ remoteName: '...' }) is called for a remote that failed to register at boot, it throws immediately — before any network request. The user navigates, the wrapper catches the error, and renders an error message in place of the remote:

try {
  const m = await loadRemoteModule({ remoteName: 'notifications', exposedModule: './mount' });
  this.cleanup = await mount(this.container.nativeElement);
} catch (err) {
  this.error = err?.message || 'Unknown error';
}

The shell doesn't crash; the other routes work normally. The immediate error (no spinner, no retry) is because the failure happened at boot, not at navigation. Switching to the remoteEntry URL approach changes this: NF would attempt to fetch the remote on navigation, where a recovered remote would succeed and a still-down remote would give a clearer network error.

Shared state across isolated runtimes

The demo components use mock data, but real applications eventually need coordination: a notification badge in the shell that reflects the Notifications remote's state, or a project selection that filters the ActivityFeed.

The constraint: framework primitives don't cross runtime boundaries. Angular's DI, RxJS subjects, signals, and React context are all scoped to their runtime instance. An Angular 18 signal is an object from Angular 18's copy of @angular/core; the shell's Angular 21 runtime can't observe it. The shared reactive graph breaks at the isolation boundary.

The neutral layer is the browser itself. Three options that work across isolated runtimes:

  • CustomEvent on window — one runtime dispatches, another listens. No shared dependency required. Simple, but events don't replay for late subscribers and you have to manually trigger re-renders on the receiving side.
  • BroadcastChannel — same API, same caveat, but also works across same-origin tabs. Useful if the same remote runs in multiple browser tabs and needs synchronized state.
  • Shell-owned singleton on window — the shell attaches a state object before remotes load; remotes read from it directly. This is the only option that gives the shell framework-native reactivity (a signal the shell's template can bind to), at the cost of an untyped global contract both sides have to agree on.

All three require the receiving side to explicitly trigger a re-render after state arrives. That's the real cost of runtime isolation — not the API surface, but the loss of automatic change propagation across the boundary.

Source: github.com/RomanenkoStud/native-federation-demo