Your coding agent just opened five PRs in a morning. Each one changes a handful of files in one product area. Each one triggers a twelve-minute build of the entire frontend: search, account, admin, marketing pages, all of it. That is an hour of CI to validate maybe two hundred lines, and the review queue is now the slowest part of a workflow you adopted to go faster.
The same twelve minutes used to be an annoyance you absorbed a few times a day. At agent throughput it is the bottleneck.
It gets worse before it gets better. You ask the agent "where does checkout read the pricing config from?" and it opens forty files across four product areas before answering, because as far as the tooling is concerned there is exactly one application and it is enormous. Then it changes six words of copy in the checkout confirmation screen, and every user on the site redownloads a main bundle whose hash moved for reasons that have nothing to do with them.
One build unit, one bundle graph, one context blob. Micro frontends in a monorepo split all three, and the monorepo part is what keeps it from turning into a distributed systems problem you did not sign up for.
The four taxes you are paying
Build fan-out. Your CI has no idea that checkout and search do not touch each other. Any commit invalidates the whole build. Build time is a function of repo size, not change size.
Bundle coupling. Webpack and Vite chunk splitting help with what gets loaded, but not with what gets invalidated. If checkout and search compile into one build output, a checkout change can shift chunk hashes across the app and evict cached bytes users already had.
Delivery coupling. Two teams shipping to the same artifact means every deploy is a merge queue negotiation. The team that is ready waits on the team that is not.
Context cost. This is the newest one and the least discussed. An AI coding agent working in a repo has a finite attention budget. Give it one 1,200-file application with no enforced internal boundaries and it will read broadly, guess about ownership, and occasionally reach into a module it had no business touching. Give it apps/checkout plus three shared libs and it reads less, guesses less, and produces a diff you can actually review.
Splitting without splitting the repo
The mistake people make is equating micro frontends with repo-per-team. That buys you deploy independence and charges you version drift, six duplicate CI configs, and cross-repo refactors that nobody ever does.
Keep one repo. Split the build.
With Nx, scaffolding the pieces is one command. Nx 23 renamed the generators: the app that loads federated modules is a consumer (previously host), the app that exposes them is a provider (previously remote).
nx g @nx/react:consumer apps/shell --bundler=rspack --providerNames=checkout,search
nx g @nx/react:provider apps/account --consumer=shell
The old @nx/react:host and @nx/react:remote generators still ship, so existing workspaces keep working, but new work should use consumer/provider. With the new generators, providers are registered at runtime from an inline list in the consumer's src/mf.ts rather than being frozen into build config.
Each provider gets its own build target and its own deployable output. Underneath, the Module Federation build plugin config looks like this:
import { createModuleFederationConfig } from '@module-federation/enhanced/rspack';
export default createModuleFederationConfig({
name: 'shell',
remotes: {
checkout: 'checkout@https://cdn.example.com/checkout/mf-manifest.json',
},
shared: {
react: { singleton: true },
'react-dom': { singleton: true },
},
});
singleton: true on React is not optional. Without it you ship two React copies, hooks break across the boundary, and you spend an afternoon on an error message about invalid hook calls that has nothing to do with your hooks.
For remotes that are not known at build time (a plugin surface, a tenant-specific module, an A/B variant), the runtime API takes over:
import { init, loadRemote } from '@module-federation/runtime';
init({
name: 'shell',
remotes: [{ name: 'promo', entry: 'https://cdn.example.com/promo/remoteEntry.js' }],
});
const Promo = await loadRemote('promo/Banner');
The performance case, stated honestly
Cache invalidation gets a boundary. Each remote publishes its own entry and its own chunks. A copy change in checkout produces new checkout bytes. Search bytes keep their hashes and stay in the user's cache. On a large app this is the difference between shipping a small delta and making every user re-download a vendor bundle because one hash cascaded.
Loading follows navigation. A remote is fetched when the route that needs it is entered. Users who never open admin never pay for admin. You can approximate this with route-level lazy imports in a monolith, but you cannot approximate the invalidation boundary, and lazy imports quietly re-couple every time someone adds a top-level import for a type.
Local dev stops rebuilding what you are not editing. nx serve shell starts the whole composed app with the remotes built and served statically. Only the remote you are actively editing needs a real dev server, and since Nx 21 you get that by serving the remote itself:
nx serve checkout
On Angular, or on webpack Module Federation without inferred tasks, the equivalent is nx serve shell --devRemotes=checkout.
Now be honest about the costs, because they are real:
- Each remote costs a manifest fetch plus an entry fetch. On a route that pulls three remotes, that is latency you did not have before. Preload the manifests for likely-next routes.
- Shared singletons only work if versions are compatible. A remote built against React 18 and a host on React 19 fails at runtime rather than at build time, which is the worst place to find out. This is the single biggest operational hazard of the pattern, and it is exactly the failure a monorepo prevents: one lockfile, one version, enforced by the repo rather than by a wiki page.
- Below a certain size this is all overhead. One team, a 90-second build, three routes? Do not do this. The pattern earns its place when multiple teams contend for one pipeline.
Where the AI speedup actually comes from
Two mechanisms, both boring, both effective.
Affected-only work. Nx computes a project graph from real imports, so it knows checkout and search are unrelated:
nx affected -t build
nx affected -t test lint
An agent that opens a PR touching only apps/checkout triggers a build of checkout and its dependents. Not the repo. Those five PRs from the opening now cost five checkout builds instead of an hour of full-frontend CI, and they run in parallel because they no longer contend for the same build. The twelve minutes did not get optimized away, it stopped being charged for work that never changed.
Boundaries the agent cannot argue with. Tag every project and let ESLint enforce who may import whom:
'@nx/enforce-module-boundaries': [
'error',
{
allow: [],
depConstraints: [
{ sourceTag: 'scope:shared', onlyDependOnLibsWithTags: ['scope:shared'] },
{ sourceTag: 'scope:checkout', onlyDependOnLibsWithTags: ['scope:shared', 'scope:checkout'] },
{ sourceTag: 'scope:search', onlyDependOnLibsWithTags: ['scope:shared', 'scope:search'] },
],
},
],
This is the part that changes how it feels to work with an agent. A prompt that says "do not import from other teams' code" is a suggestion the model may or may not follow. A lint rule that fails CI is a fact. When an agent takes the shortcut of reaching into apps/search/src/internal/pricing.ts from checkout, the build tells it no, and it fixes the mistake in the same session instead of you finding it in review three days later.
The context effect compounds. Scoping an agent to one remote plus the shared libs means the files it reads are the files that matter. Smaller context, fewer distractor files, more of the budget spent on the actual change. The same architecture decision that gave you independent deploys gave you a natural unit of work for an agent, which is not a coincidence: both are asking for the same thing, which is a piece of the system you can reason about without loading the rest.
Migration path that does not stall
Do not carve up the whole app. Pick the boundary that hurts most, usually the one where two teams collide in the merge queue, and extract exactly that one.
- Create the consumer, keep the existing app as the first provider. Nothing changes for users.
- Extract the contended area into a second provider. Shared code goes to
libs/shared, taggedscope:shared. - Turn on
enforce-module-boundariesin warn mode, fix the violations it finds, then flip it to error. - Switch CI to
nx affected. Measure the build time delta before extracting a third remote.
If step 4 does not show a meaningful improvement, stop. You have a dependency-graph problem, not an architecture problem, and adding remotes will not fix it.
Key takeaways
- Agent throughput turns a tolerable build time into a hard bottleneck. Five PRs a day made twelve minutes annoying; five PRs an hour makes it the constraint on everything.
- Micro frontends buy independent build, cache, and deploy boundaries. A monorepo keeps the single lockfile and single-commit refactors. You want both, not one or the other.
- Mark framework packages
singleton: trueinshared, or hooks break across the remote boundary at runtime. -
nx affected -t buildturns build time into a function of change size instead of repo size, which is what makes multi-PR agent work practical rather than theoretical. -
@nx/enforce-module-boundarieswith tags converts your architecture from a convention into a CI failure, and an AI agent respects a failing build far more reliably than a prompt. - Extract one boundary, measure, then decide. Below a couple of teams and a couple of minutes of build time, this pattern costs more than it returns.
Docs worth reading before you start: Nx Module Federation and Module Federation Core.
Top comments (0)