Four pivots to one frontend shell
TL;DR
- Three months turning a fleet of independently deployed React apps into one composed platform: one "shell" app that loads the others into the same page, so going from one app to another no longer reloads it. The plan changed four times.
- Status, because it changes how you should read everything below: 8 apps of ~13 are absorbed and live in production. This is a mid-flight report, not a retrospective on a finished thing (the November 2026 update at the end says where it stood later).
- Three of the four pivots trace to one root cause, and the honest lesson isn't "we learned by building": we put a pre-release bundler underneath the composition layer, and paid for it three times.
- And one mistake in particular is mine: in pivot 3, to make a cleaner option viable, I quietly relaxed a constraint I had written down as hard. Three days later it came back and killed that option.
- What it bought (one measured run per topology): a hop to another app went from 1251 KiB and a full reload to 10.4 KiB with state kept. The price: a heavier, slower first load, which I traced to a bootstrap choice rather than to Module Federation itself, and adding one more app to the shell took the whole platform down, in a way the dev environment cannot reproduce.
The pain, in three layers
Each app had its own repo, its own owners, its own deploys and its own URL. That hurt in three layers:
- Release coordination. 10 to 20 library version bumps a week, each one times N apps. Hundreds of manual actions a week: dependency PRs, release tickets, manual promotions (moving a build to the next environment). A meaningful fraction of an engineer's year, permanently.
- UX. Full document reload between apps. Lost scroll, lost state. Users perceive N disconnected products, because that is literally what they are.
- Tech debt. React, the design system and the auth library re-bundled in every app. Framework drift, the apps' setups slowly diverging. No cross-app E2E test, no cross-app state.
And three hard constraints:
- Apps cannot be merged: separate repos, ownership, deploys and URLs stay.
- The migration must be progressive and non blocking for feature work.
- Each phase is opt-in per app.
That last one kills most of the elegant answers before you start.
Plan 1: audit, then cheap, then maybe expensive later
The committed sequence was:
- Normalize the fleet: bring every app to the same setup.
- Spike a shared navigation Web Component (a browser-native custom element any app can embed).
- Ship shared singletons over CDN: the design system, auth and translations hosted once, as one copy every app loads.
- Defer the runtime shell (one app that loads the others into the same page while the user browses) to a decision a year out.
Sensible. Cheapest thing first.
What the audit found
The audit found drift across 13 React apps, distributed like this:
| Drift level | Apps |
|---|---|
| Compliant | 5 |
| Mid | 5 |
| Heavy | 3 |
11 of 13 diverged on TypeScript path aliases alone, and 9 systematic patterns were fixable by a single codemod (a script that rewrites code across a codebase) or template change.
I originally wrote that up as "bimodal, not much in between". The numbers say otherwise and I should have read my own table: the middle is tied for the largest group. Half the fleet being mid-drift is a more common situation than a clean split, and more useful to say.
12 apps normalized in five days
Then the execution number that surprised me most: 12 apps normalized in five days. The estimate had been two to three engineer-months. Codemod-driven fleet work is drastically cheaper than it looks when the drift is systematic, which is the actual argument for auditing first.
Composing apps into one shell, in two minutes
The rest of the story keeps switching between two ways of putting separate apps into one page, so here they are.
The shell is the one app the user loads. It owns the React root and the router, and renders the other apps inside it, so moving to another app no longer tears down the page. There are two ways to get the other apps' code into it:
| Runtime composition | Build-time composition | |
|---|---|---|
| How the shell gets an app | downloads its code in the browser, from a URL | imports it as an internal package of a frontend monorepo, and lazy-loads it |
| Deploys | each app can keep its own | gives up strict deployment independence |
| Shared libraries | the apps agree in the browser on which copy to use: a runtime version contract | one lockfile, one version policy, nothing to agree on at runtime |
Where the code lives (many repos or one monorepo) and when the apps are put together (at build or at runtime) are separate questions. That matters in pivot 3.
Module Federation (MF) is a way to do runtime composition. The shell is the host; each app it loads is a remote, built separately and meant to deploy on its own. MF needs support in the bundler that builds the apps, and that detail is where three of the four pivots came from.
Pivot 1: what changed the plan
Five days after the audit completed, we cancelled the Web Component spike and the CDN singletons outright and started the expensive option, the runtime shell, immediately.
The conversation that forced it
Transitions between apps are frequent and the journeys are deep multi-hop chains: a user goes through several apps in a row. There is no isolatable subset of apps you can do first. Which means SPA continuity between apps is the requirement, not a nice-to-have: going from one app to the next must work like one single-page app, with no reload.
CDN singletons deduplicate bytes, they do not give you that. We would have spent months buying the wrong thing.
The talk that made the answer credible
At React Paris, 26-27 March 2026, Taylor Quinn-Bohmann (Staff Software Engineer at Tesla) gave Share Once, Ship Everywhere: Federated UI at Scale: Module Federation to share live, data-rich React components at runtime, without synchronized releases or dependency headaches.
What made it useful wasn't the architecture, it was the problems they had already hit and solved. A blog post shows you the final diagram; a good talk gives you the list of things that broke on the way there. So when the journey conversation forced the question seven weeks later, Module Federation wasn't a research project, it was a thing I'd watched someone run well above our scale.
I want to be careful about the causality, because my own notes from the pivot day, written at the time, credit only the conversation. The talk isn't in them. Reconstructing it now, it's what made "start the expensive option today" feel like a two-month decision rather than a two-year one, but I can't claim it flipped anything on paper. Treat that as memory, not record.
What the audit still bought
The audit still paid for itself, just not the way it was scoped to. With the fleet aligned (same React major, canonical build config, canonical auth client), the estimate for the shell roughly halved. That is a re-estimate, not a measured saving. Nobody has costed what it actually took.
Pivot 2: the tooling wasn't there
First spike, week one. Both Module Federation plugins for our bundler crashed on the version we were on. Not "had rough edges", crashed.
We replaced them with a native dynamic import(url), which loads a JavaScript module from a URL at runtime, plus library mode on each remote, which builds the app as a module to import rather than as a page. The result: a single-file ESM (ES module) bundle per remote, 1.6 KB, loaded into the host with Suspense and an error boundary. Three of seven acceptance criteria proven in week one.
A spike that reveals your tooling is unusable is a successful spike. It cost a week and saved us from finding out in month four.
Pivot 3: the constraint I quietly relaxed
Two weeks later, with singleton sharing proven live (zero reload, shared auth, store, feature flags and design system, one React root), we abandoned runtime composition entirely and moved to build-time composition in a frontend monorepo, where the shell lazy-imports each app as an internal package.
Here is the part that would be easy to tell flatteringly, and I'm not going to.
The analysis had already settled this a day earlier, on paper: given a hard separate-deploy constraint (the first constraint above: deploys stay separate), runtime composition is required regardless of repo topology. The axes are orthogonal. That was written down.
So how did build-time composition get chosen the next day? Because I revised the constraint. I decided the product objective (zero reload) took precedence over strict deployment independence. Once you relax that, build-time composition is clearly cleaner, and it is: one lockfile, one version policy, no shared-singleton negotiation, no runtime version contract.
Three days later we reversed it and went back to Module Federation, on a different bundler.
The transferable lesson is not "we built it to learn the constraint binds". The analysis had the right answer before anything was built. The lesson is: the requirement you quietly relax to make an option viable is the one that comes back and kills it. If you catch yourself revising a constraint you had written down as hard, that is the moment to stop and get it re-agreed explicitly, not to keep going because the new option looks cleaner.
Pivot 4: the root cause was the bundler
The return to Module Federation came with a correction I'd rather not have needed.
We had justified pivot 3 partly as "runtime Module Federation isn't mature enough". That was a mis-attribution, and our own retrospective says so. MF is Webpack-native and production-mature on the webpack-family bundlers. The blocker was never MF; it was that our bundler was pre-release and had dropped native MF support, deferring to a still-green port (an MF port for it that was not mature yet).
Trace it back and three of the four pivots have one cause:
- Pivot 2: the plugins crashing.
- Pivot 3: "runtime composition isn't ready", which was the bundler not being ready.
- Pivot 4: the correction.
And the bundler's MF status was a public fact the whole time, evaluable in an afternoon before any of it.
That's the boring-technology argument in its purest form. The novel component sat in the foundation of the composition layer, where it bought us nothing, and it cost three plan reversals.
Shared libraries and manifests, the short version
The measurements and the outage below rest on three more pieces of Module Federation.
The share scope. By default each app bundles its own copy of every library it uses. In the federation config, the host and the remotes list the libraries they share instead: React, the router, the design system, the auth library. At runtime each app offers its copies to a common registry, the share scope, and the apps use copies from there. A library marked singleton asks for one instance for the whole page. The app whose copy gets used provides it, and that can be a remote, not the host. How the runtime picks that copy is the subject of the outage.
Registering remotes. Our shell registers every remote at startup, telling the federation runtime where each remote's code lives. Doing all of them at boot is a choice, and its cost comes up in the measurements below.
Manifests. Each environment has a federation manifest that tells the shell which build of each remote to load. A pinned manifest points each remote at a specific build, so one environment can mix builds from weeks apart. Dev follows a moving channel instead, so every remote comes from one commit.
flowchart LR
M["Environment manifest: which build of each remote"] --> S["Shell (host)"]
S -->|"registers at startup"| A["Remote A"]
S -->|"registers at startup"| B["Remote B"]
S -->|"offers its copies"| SC["Share scope"]
A -->|"offers its copies"| SC
B -->|"offers its copies"| SC
What it bought, measured
How I measured, and why n matters
Method first, because n matters:
- One run per topology, cold cache (nothing in the browser cache), driven through the browser devtools protocol, on a fast low-RTT network (short round trips to the server).
- The standalone side had its pod uninstalled (it was no longer deployed), so its
index.htmlwas served locally and +52 ms was added to its timings to compensate a TTFB (time to first byte) artefact. - Both runs stop at the SSO redirect (the single sign-on login), so no post-auth Core Web Vitals were measured: none of the standard user-experience metrics after login.
- The two sides are separate ports of the app (re-ported), not byte-identical, and use different bundlers with different chunk splitting (how the code is cut into files). Totals are comparable, "own code" figures are not.
Cold first load: federation loses
Bytes are brotli-compressed sizes. Serial waterfall depth is the length of the longest chain of requests where each one can only start after the previous one has finished.
| Federated | Standalone (adjusted) | Delta | |
|---|---|---|---|
| Bytes (brotli) | 1408 KiB | 1221 KiB | +187 KiB (+15%) |
| Serial waterfall depth | 7 levels | 3 levels | +4 |
| All app code available | 370 ms | ~209 ms | +161 ms |
The +161 ms is the lab number and it's the least interesting one. The waterfall-depth gap scales with round-trip time: on 4G at ~150 ms RTT, those 4 extra serial levels are roughly +600 ms. That's the figure that would actually change someone's decision.
Moving between apps: federation wins, not close
| Hop to another app | Federated (in the shell) | Standalone |
|---|---|---|
| Downloaded | 10.4 KiB | 1251 KiB |
| Page | no document teardown | full reload |
In the shell, React state, store and query cache all survive. (A second measurement, ~41 KiB, counts one app's own code excluding the singletons it provides; different accounting, don't read the two as a range.)
Two findings I did not expect
The remote is 96% shared libraries. A 962 KiB remote contained 920 KiB of singletons it provides to the share scope (737 KiB of that is the design system) and 41 KiB of its own code. Dedup works. But the remote is the provider, not the host, so the design system only starts downloading at hop 8.
The standalone topology has zero cache reuse. Each standalone app ships the design system under its own content hash (a file name derived from the file's content), so no app reuses another's copy. Three standalone apps means the same design system downloaded three times, ~2.3 MB.
The first-load penalty is a bootstrap choice
And the conclusion that mattered most: the first-load penalty is not intrinsic to Module Federation. Roughly 94 KiB and 3 to 4 of the 4 extra hops come from eagerly registering every remote at boot, before the app renders. That's a bootstrap choice, and it scales linearly with absorbed apps (94 KiB at 4 apps, ~470 KiB projected at 15) while being paid on every page load regardless of route.
What the tables leave out
The cost this table doesn't show, and should. We collapsed a dozen independent failure domains into one. Before, a broken app was a broken app. After, one bad remote registration is the entire platform behind a single error page. That is the central architectural trade of this migration, and measuring it in bytes and milliseconds while leaving availability out of the ledger is exactly the mistake I'd call out in someone else's write-up.
And we never re-measured layer 1. The migration was justified on release-coordination pain quantified in engineer-time. Everything above is measured in KiB and ms. With 8 of 13 apps absorbed we have also added plumbing:
- a monorepo
- a second bundler in the fleet
- a per-environment federation manifest pipeline
- a bespoke deploy path
- a new class of production incident
Whether that nets out on the original metric, I don't know. We haven't measured it. That's a gap, not a rhetorical concession.
The outage
Registering one new remote took the whole platform down: production behind a single error page, and test and staging before it.
How one more remote ended up providing the shell's router
Each entry of the shared-library config can be marked eager, which loads that app's own copy of the library up front instead of lazily. This eager is a setting on one shared library, not the eager registration of remotes from the measurements.
Mechanism: the shared-singleton config treated eager as a per-entry preference, decided library by library. Only React and React-DOM were host-provided. Every other singleton in the host's synchronous boot tree (what the shell needs before it can render) was lazy, therefore order dependent. Registering one more remote flipped the order, and a remote ended up providing the shell's router. The failing call sat above the app boundary, so the root error boundary caught it and took everything with it.
flowchart TD
H["Shell boots"] --> E["React, React-DOM: eager, host-provided"]
H --> L["Router and other boot-tree singletons: lazy, therefore order dependent"]
L --> N["One more remote registered: the order flips"]
N --> X["A remote provides the shell's router"]
X --> F["Failing call above the app boundary: root error boundary, single error page"]
We had seen this risk, and accepted it back
We had identified this exact risk and then accepted it back. The build-time decision of pivot 3, nine weeks earlier, listed as its number-one rationale that it structurally eliminates the shared-singleton version contract: one lockfile, single-version policy, no negotiation. We chose that architecture partly to kill this risk, reverted three days later for deploy independence, and carried no mitigation across the reversal. The mitigation was invented after the outage.
Seven things worth stealing
A version tie is the dangerous case, not a mismatch. A mismatch (the apps offer different versions) logs a warning and carries on with one instance. A tie (they all offer the same version) silently defers to load order. Every remote was on the identical router version, which is exactly why it broke.
Note this depends on running
strictVersion: false, and understrictVersion: truea mismatch throws instead.falseis still the right call, it stops a patch skew (apps a patch version apart) taking a route down, but the price is that your only signal is a console warning in a browser nobody instruments.Non-prod broke 58 minutes before production. Test and staging were already dark with the identical defect when the change was promoted. That's the process finding, and it's worse than the technical one: there was no gate that loads the composed shell against the target environment's pinned manifest before promoting. The defect isn't in any artifact, it's in the combination, so only the combination can be tested.
We don't know the detection time. It was never quantified, and that's structurally hard here: every asset returned 200, the origin (the server) was healthy, the failure was entirely in the browser. A client-side composition failure produces no server-side signal at all. The missing controls are error reporting on the shell's root boundary and a synthetic, an automated check that loads the composed shell per environment.
The throw location decides the blast radius. The failing call sat above the app boundary, so the root error boundary ate everything. But be careful generalising this: what broke was the router, and if the router can't resolve there are no segments to route to, so a per-segment boundary (one error boundary per routed part of the page) would have caught nothing. Boundary placement would have contained the three providers after it in the chain. It structurally cannot contain the router.
Fixing the reported symptom cost a second cycle. Making only the router eager shipped, then immediately surfaced the next provider in the chain. Four providers, same mechanism. Fix the rule, not the instance.
Dev could not reproduce it, and never will. Dev follows a moving channel, so every remote comes from one commit. Pinned environments mix builds from weeks apart. That asymmetry is the bug class, and it's a permanent property of the topology, not a gap you close by testing harder on dev.
The rollback lever needed no deploy. In a per-environment pinned-manifest topology, the manifest is the rollback surface, and it is far faster than shipping anything: drop the offending remote keys and the platform comes back. Worth knowing.
Also worth saying that ours was a hand-edit on a CDN, done live by three people, with no review and no audit trail, immediately diverging from the gitops source of truth (the git repo the deployments are meant to follow). That's a second incident queued behind the first. The manifests are already versioned per environment, so a reviewed one-command revert is small work and a large reduction in 3am risk.
The fix, and the check that would have caught it
The repair was a code change: make every host singleton eager, shipped across two cycles. What prevents recurrence is a CI assertion that every host entry is eager, no remote entry is, and both maps (the host's and the remotes' shared-library lists) carry the same names. Don't let me imply a lint rule fixed a production outage.
And that assertion is necessary but not sufficient. It enforces one rule learned from one failure, on a config file. The failure class is runtime composition of independently versioned artifacts, and its next instance won't be an eagerness mistake, it'll be a peer-dep skew (a peer-dependency version mismatch) or a remote built against a design-system major version the host doesn't have. No config lint sees those.
The control that matches the class is a smoke test that gates promotion:
- Boot the composed shell against each environment's real pinned manifest.
- Assert it renders.
- Promote only if it does.
That's also the control that would have caught the 58-minute window.
What I learned
- Write down which constraints are hard before you evaluate options. Then notice when you start revising one to make an option work, because that's the failure mode, not a clever insight. Build the thing you're unsure about and deploy it for real, but don't mistake that for having no analysis: ours had the right answer a day before we built the wrong thing.
- Keep the novel technology out of the foundation. A pre-release bundler under the composition layer cost us three plan reversals and bought nothing.
- Normalize the fleet first even if you throw the plan away. 12 apps in five days, and it roughly halved the estimate for what came next.
- Measure both topologies on the journey users actually take, publish your n, and put availability in the ledger next to the bytes.
- Go to conferences and listen for the failure list rather than the architecture.
- Your composition layer will fail in ways your dev environment structurally cannot reproduce. Gate promotion on the composed shell against each environment's pinned manifest. The next runtime-composition failure was a version mismatch, the case I had said was not the dangerous one: my "tie, not mismatch" point only held after a config change (see the November 2026 update).
Update, November 2026
The substance above stays as published; four things in it turned out differently.
The status line is stale, and so is its denominator. I wrote "8 apps of ~13 are absorbed". By late October the shell served 14 remotes, all in production: 11 ports of standalone apps and one of a template, old repos archived, plus an app built new in the monorepo and a settings page. Against the old 8, the nearest like-for-like figure is the 11 ports.
The deploy registry still lists 13 standalone front-ends, one kept out on purpose because it needs tooling the monorepo lacks. So: 14 in, 13 out. I can't reconcile that with the audit's 13 React apps: my notes don't say which ended up where, and I won't invent a mapping.
A mismatch can be the dangerous case. I wrote "A version tie is the dangerous case, not a mismatch." A month after the outage, a mismatch took test and staging down. The shell carried the auth library one minor behind seven remotes deployed after it. The default share strategy, version-first, takes the highest version on offer, so the remotes' copy won, a second instance ran, and the shell's auth provider was lost. Production survived only because its remotes were pinned to older builds. The section above predicted version skew next. It was, smaller than I guessed: one auth-library minor.
Five days later we merged shareStrategy: 'loaded-first' into the shell and the shared remote config: the copy already loaded, the shell's eager one, wins whatever its version. I trusted it only after a failing control: same manufactured skew, one string changed, and version-first reproduced the outage's error verbatim. Only after that switch is my sentence true: two days later a real production skew, the shell's design-system library one minor behind a remote's range, logged one warning and kept one instance.
The first-load penalty: right symptom, mechanism probably one level lower, fix unmeasured. I put "Roughly 94 KiB and 3 to 4 of the 4 extra hops" on eagerly registering every remote at boot. Reading the federation runtime later moved it one level down: under version-first, startup loads the entry file of every registered remote, on the current route or not, to collect versions, on top of the shell's own manifest fetch and preflight. loaded-first skips that loop. I have no cold-load measurement since it shipped, so the "~470 KiB projected at 15" is unverified: it extrapolates a cost whose main suspect has since been switched off.
Deploy independence wasn't real yet. We "reverted three days later for deploy independence", but the shell still compiled translations, reducers and an API client out of the remotes. Shipping a remote alone would serve half a pair, so every remote release was also a shell release: 31 shell releases in the six days to mid-September, most of them changing nothing in the bundle. Of 159 merges touching a remote, counted days earlier, only 75 changed what the shell compiled.
Cutting it took the second half of September, expand then contract per remote: deliver its own translations and state at mount, then stop the shell providing them. A stopgap rule kept shipping the shell with every remote until we removed it too. The first prod deploy of a remote with the shell untouched, one manifest write, came mid-way, which says where the coupling was: repinning a remote never needed the shell, releasing one did.
By late October prod and test differed on 12 of 14 pins under one shell. My only re-measure of layer 1 is thin: in 19 hours after the rule went, 2 shell releases for 4 remote ones, both with real shell changes. That shows the mechanism works, not that the rate holds. Weekly shell tags fell from 48 and 51 to between 5 and 8, back to their late-August level, so I can't credit the drop to the fix.