The federation setup had been the stable part of the system for years. It was the build that moved.
Angular's esbuild-based application builder had been the default for new projects since v17. The webpack browser builder was deprecated. Every release, the update tooling asked — politely — whether I would like to migrate. The answer had to be yes eventually. And the moment it was, a federation configuration that had worked without incident since the platform was assembled had nothing left to attach to. Module Federation, in Angular, is a webpack plugin. Take the webpack builder away and there is no plugin, no remoteEntry.js, no shared scope. Not broken. Absent.
That was the week I stopped thinking of federation as a bundler feature and started thinking about what the bundler had actually been providing.
Two implementations of the same idea
Module Federation's lasting contribution is a mental model, and it is a good one. A host loads code from a remote at runtime. The remote is built and deployed on its own, and it exposes some of its modules. Both sides declare shared dependencies, so the framework is downloaded once instead of once per application. And when two applications disagree about a version, there is a defined negotiation rather than an accident.
Nothing in that paragraph mentions a bundler.
The webpack implementation is nonetheless deeply a bundler thing. ModuleFederationPlugin rewrites module resolution during the build, emits a JavaScript remote entry that acts as a container, and wires up a runtime that fills the shared scope as modules are requested. It is clever and it works. It can also only exist inside a build graph, which is why it went missing when the build graph did.
Native Federation, from @angular-architects/native-federation, keeps almost all of that model — every part except the version negotiation, which I will come back to. It just builds it out of things the browser already has: ES modules for loading, and import maps for deciding where a bare specifier points. There is no JavaScript container. Each remote publishes a remoteEntry.json instead — a small file saying what it exposes and what it shares. The host reads those at startup and turns them into an import map.
The config is meant to look familiar. Here is a remote's federation.config.js.
const { withNativeFederation, shareAll } = require('@angular-architects/native-federation/config');
module.exports = withNativeFederation({
name: 'mfe-reports',
exposes: { './Reports': './src/app/reports/reports.ts' },
shared: { ...shareAll({ singleton: true, strictVersion: true, requiredVersion: 'auto' }) },
});
The host is initialized before Angular bootstraps, because the import map has to exist before the first module resolves:
// main.ts — federation first, then the app
import { initFederation } from '@angular-architects/native-federation';
initFederation('federation.manifest.json')
.then(() => import('./bootstrap'))
.catch((err) => console.error(err));
And a remote is loaded by name, through an ordinary lazy route:
// app.routes.ts — the host never imports the remote's code
{
path: 'reports',
loadComponent: () => loadRemoteModule('mfeReports', './Reports').then((m) => m.Reports),
}
What that produces is a plain JSON file on the remote's origin, which is the whole substitution in one artifact:
GET https://reports.example.com/remoteEntry.json
{ "name": "mfe-reports",
"shared": [ { "packageName": "@angular/core", "version": "21.2.9",
"singleton": true, "strictVersion": true, … } ],
"exposes": [ { "key": "./Reports", "outFileName": "Reports-PX3XVL6J.js" } ] }
A build tool produced that file. Nothing about consuming it requires knowing which one. One caveat on what you are reading: singleton and strictVersion ride along in there as metadata. The runtime keys off packageName@version and nothing else.
What it gave up
A post that lists only the wins is a brochure, so here is the other column.
The webpack plugin ecosystem does not come along. Say your setup leaned on other webpack plugins. A custom loader in the remote's pipeline. A plugin that rewrote something on its way through the graph. A trick that reached into webpack's own runtime. Some of those have an esbuild counterpart. Some do not, and then the honest answer is that you rewrite the behavior or you drop it.
Version negotiation does not survive the move, and this is the one to know about. Native Federation's runtime dedupes shared packages on an exact packageName@version key. That is the whole algorithm — no ranges, no semver, no strictVersion, which appears nowhere in the runtime despite being written into every remoteEntry.json. Matching versions get one copy. Versions that differ by a patch quietly get two, and for @angular/core that is an afternoon of your life. The demo below gets a single copy of Angular because all three applications are built from one workspace at the same version, not because anything negotiated it.
You also give up what came from federation living inside the bundler. webpack held the module graph and the federation config at the same moment, so it could decide with both in view. Native Federation's contract is a JSON file and an import map. That is a much thinner seam, by design. Thin seams are portable and less clever, and both halves of that are true.
One more thing worth saying plainly. Import maps are the load-bearing browser feature here, and every federated import goes through es-module-shims. Not as a fallback for browsers that lack them — import maps have been widely available for years. The shim is there because the map is assembled at runtime, after the page has loaded, and it runs in shim mode by default, so a modern browser's native support is bypassed along the way. Not a hidden cost, but if you like knowing exactly what runs in your page, that is a piece of it.
The choices that were actually on the table
Stay on webpack. The webpack builder still runs, and for a large federated setup with real plugin dependencies, "not yet" is a defensible engineering answer. I would still not start something new on it. Not because it is bad, but because the browser builder is deprecated and the framework's own tooling work has moved on. Picking a deprecated builder means picking a fixed amount of runway. Sometimes that runway is longer than the life of the project, and then it is the right call.
Ship one application. This is the option that gets skipped, and it deserves more respect than it gets. Federation buys independent deployment across team boundaries. If you do not have those boundaries, you are paying a lot for autonomy nobody asked for. One team, one release train, one product surface — and still runtime composition, cross-origin loading, version checks, and a fresh class of integration bug. A lazy-loaded route does the same job with none of it. Ask hard whether the split is real before you pick either library.
Native Federation. For new Angular micro-frontend work on the esbuild builder, this is what I would pick. It keeps the mental model teams already have. It stays on the supported build path. And it hands the work to Angular's own builder instead of forking away from it. That last part matters more than it sounds: federation stops being a reason to fall behind on the framework.
You can watch this run on the federated shell demo. The host fetches a manifest and resolves two remotes it has no compile-time reference to. It then loads reports and settings from separate origins into the same shell, with Angular and the design system shared as one copy across all three.
The Model Was Never the Bundler's to Own
Module Federation described something true about distributed frontends: composition can happen at runtime, and the pieces can be owned separately. That was worth more than the plugin that first shipped it. Tying it to one bundler made it look like a webpack feature instead of a pattern. Native Federation is what it looks like when the platform catches up: the same model, written in modules and import maps, portable to whatever builds your code next. Keep the model. Hold the tooling loosely.


