The upgrade was supposed to take an afternoon. A minor Angular bump in the shell, a changelog with nothing alarming in it, a host application that built without a single warning. I pushed it to a preview environment and went to get coffee.
Then I clicked into one of the remotes, and the page stopped.
The console had a version error where the view should have been. The shell had already loaded its freshly upgraded Angular. The remote I clicked into would not accept it. The federation runtime looked at both claims, decided they could not be reconciled, and refused. Nothing was broken inside the host. Nothing was broken inside the remote. The break lived in the gap between them, and that gap had no repository, no pipeline, and no owner.
The version number that lives in seven places
The shape of it: a platform made of seven-plus micro-frontends. Each one lived in its own repository. Each was owned by a different team, on its own release train. A host application pulled them together in the browser with webpack Module Federation, so a user moving between sections was moving between separately built and separately deployed applications without ever knowing it.
That works because federation does not resolve shared dependencies at build time. It resolves them when the page runs. Every build emits a shared block that says what it brings and what it will accept, and at load the runtime reads all of those claims and tries to satisfy everyone with one copy.
// this block exists in the host and in every remote — seven-plus near-identical copies
shared: {
'@angular/core': { singleton: true, strictVersion: true, requiredVersion: '~21.1.0' },
'@angular/common': { singleton: true, strictVersion: true, requiredVersion: '~21.1.0' },
}
singleton: true says there may be only one copy of this library in the shared scope. requiredVersion is the range each side will accept — ours were tilde ranges, so a patch release was welcome and a minor was not, which is the detail that turns one bump into a platform-wide event. strictVersion: true is the part that decides how loudly the system fails: without it, a singleton whose versions disagree still loads — the runtime keeps whichever copy loaded first, otherwise takes the highest version present, and prints a warning that a busy team will scroll past. With it, the mismatch throws instead.
That is the error I was looking at:
Uncaught (in promise) Error: Unsatisfied version 21.2.0 from shell of shared
singleton module @angular/core (required ~21.1.0)
One repository had moved. Six had not — and because the shell boots first, its copy is the one every remote has to accept or refuse. Moving ahead alone broke the platform exactly as hard as falling behind would have. Because Angular ships as a co-versioned family of packages, moving one of them means moving all of them — so the bump was not a line in package.json on its own. It was a line in package.json and a line in the federation config, in every repository, at the same time.
This is the part that reframed the whole system for me. I had been treating the seven repositories as seven independent units with a thin runtime seam. They were not independent. They were one distributed build with a version contract written across it in longhand, and the contract had no single place where it could be checked.
Nobody's CI was wrong
Each repository's pipeline did exactly what it was designed to do. It installed dependencies, compiled, ran unit tests, built a bundle, and reported green. It could not have caught this, because the thing that failed did not exist yet. The failure was a property of the assembly, and the assembly only happened in a browser, after deploy, when a user clicked a link.
I have shipped that gap more than once. It is easy to build: you split a frontend for good reasons, you give each team a repository so they can move at their own speed, and the seam between them stays implicit because in the first six months nothing crosses it. Then a framework upgrade crosses all of it at once, and you find out that the thing you called a boundary was really a handshake nobody wrote down.
The scheduling cost is worse than the technical one. Seven pull requests, in seven review queues, on seven team calendars — and because a partial rollout produces exactly the error above, they have to land close enough together that no user sees a half-upgraded platform. A one-line dependency change turned into a release plan.
What I would weigh, and what I would pick
There are real alternatives here, and I have watched teams choose each of them for defensible reasons.
Pin everything and upgrade rarely. Freeze the framework version across all repositories and revisit it once or twice a year. This does reduce how often you pay the coordination cost. It also means each payment gets larger: a year of skipped minors is a much harder migration than twelve small ones, and security patches sit behind the same wall. You are not avoiding the tax. You are deferring it at interest.
Drop the singleton. Set singleton: false and let each remote carry its own framework copy. The version conflict disappears, and so does the coordination. What you buy it with is bundle weight — several copies of Angular over the wire — and a class of bug that is much harder to explain than a version error, because two copies of a stateful library type-check identically and share nothing at runtime. I wrote about the exact shape of that in Two Copies of One Singleton, and I would take a loud version error over it every time.
Publish a shared config package. Move the shared block into an internal package that every repository consumes, so the version contract is written once. This is a genuine improvement and I would do it regardless — it removes the copy-paste drift where one repository's config falls out of step by accident. But it does not remove the lockstep. Updating that package still means every repository has to install the new version and deploy, in a window, before the platform is consistent again. You have made the contract easier to state. You have not made it cheaper to change.
Put the applications in one repository. One workspace, one dependency graph, one commit that moves the framework everywhere at once, one pipeline that can see every consumer and rebuild all of them. The upgrade becomes a normal pull request with a normal review, and the assembly is tested before it merges rather than after it deploys.
That last one is what I would choose for a platform inside a single organization, and I want to be honest about what it costs: it is a migration, not a configuration flag. You are consolidating repositories, rebuilding pipelines, and asking teams to accept that other people can see and change their code. That is a real conversation, and it takes longer than the upgrade that prompted it. What you get back is that the failure mode I opened with becomes impossible to create — not caught earlier, but structurally unavailable, because there is no longer a way for two applications in the same product to disagree about which Angular they are running.
Independent deployment survives the move, which is the part people expect to lose. Separate repositories were never what made the remotes independently deployable — the runtime federation seam was.
A Boundary You Can Only Test After Deploy Is in the Wrong Place
Every architectural boundary makes a promise about what can change on one side without disturbing the other. The promise is only worth something if you can verify it before a user does. Seven repositories sharing one framework version could not make that promise: the contract lived between the repositories, so the first honest test of it was a browser in production. When the only place a change can be proven is the assembly, the assembly is where the boundary should have been drawn — or the boundary should not be there at all.


