Most Module Federation write-ups stop at the config. You add the plugin, you name a few exposes and remotes, the demo loads a button from another build, and the post ends. That part takes an afternoon.
The part that decides whether this survives contact with several teams is the contract between the shell and the remotes.
What we were splitting
A React frontend for a government events platform, built as one application, released as one unit. Every team waited for the slowest team in the release. Nothing shipped alone.
The goal was not novelty. It was letting teams deploy independently without turning every deploy into a coordination meeting.
Why the config is the easy half
With Module Federation, the shell loads remote modules at runtime. That runtime step is exactly where the guarantees you had at build time disappear.
In a monolith, a mismatch between two parts of the app is a build error. You cannot ship it. After the split, the shell and the remote are built at different times, by different pipelines, possibly weeks apart. A mismatch is no longer a build error. It is a blank panel in production, or a shared library initialised twice, or a hook called against a React copy the shell does not know about.
Nothing in the bundler config prevents that. The config is what makes loading possible. It says nothing about what is safe to load.
The contract
So we made the boundary explicit, and versioned it.
Each remote declares what it exposes and what it expects: the props of the exposed module, the shape of the events it emits, and the range of the shared dependencies it was built against. The shell checks that range before it mounts anything. If a remote sits outside it, the shell refuses it and renders a defined fallback, rather than mounting a component that will throw halfway through a user's session.
Three things this gets you:
- Failures move left. A mismatch is caught at load, in one place, with a message naming the remote and the expected range, instead of surfacing as a broken screen a user reports two days later.
- Independent deploys become boring. A team ships when their remote satisfies the contract. No release train, no queue.
- Shared dependencies stop being folklore. React and the design system are declared as shared with explicit ranges, so "which version is actually loaded" has an answer you can read instead of guess.
The release cycle came down by about 40%. Most of that was not build time. It was the waiting that disappeared.
What I would tell someone starting this
Write the contract before you touch the build config. The config is reversible in an afternoon. A contract that was never written down becomes tribal knowledge, and tribal knowledge is what breaks when someone new ships their first remote.
Decide what happens when a remote fails to load, before it happens. Shell fallback, error boundary, feature flag, take your pick. The default is a blank rectangle, and the default is the one you get if you do not choose.
Keep the shared list short. Every shared dependency is a coupling you have to version. React and the design system, yes. A date library, probably not.
Do not split by page. Split by ownership. The seam should follow the team that maintains the code, because the point of the exercise is independent deploys, not smaller bundles. Smaller bundles are a side effect, and you can get those from code splitting without any of this.
When not to do this
If one team owns the whole frontend, this is overhead with no payoff. You get the coordination cost of a distributed system and keep the release cadence of a monolith. Code splitting and a clean module boundary will serve you better.
Micro frontends solve an organisational problem. If you do not have that problem, the bundler config is not going to give you one worth having.
Originally published at theadnansaleem.com.
Top comments (0)