Microfrontends Need a Platform, Not Just a Boundary

The hard part of a microfrontend migration is not splitting the code. It is making independence safe enough that other teams can use it without the original builders in the room.

Kevin GloverKevin Glover
September 30, 202616 min read

There is a version of a microfrontend migration that looks clean in a diagram.

A monolith gets too large. A product surface needs clearer ownership. A team extracts the frontend. A host loads a remote. The code moves somewhere else. The boxes separate, the arrows still connect, and the architecture looks simpler because its shape is easier to explain.

But, the harder parts tend to show up somewhere between those boxes. 

They show up when the host and remote disagree about which copy of React owns the page, when an entry file points at assets that were only partially published, when a remote throws during rendering and takes the rest of the page down with it, or when CSS loaded for one product surface quietly changes another, or when local development requires three Slack threads and a person who “just knows how it works.” 

They fail when independence is treated as a repository boundary instead of an operating model.

That was the more interesting problem underneath Calendly’s microfrontend work. We began with a large React application embedded inside a Rails monolith and a practical need to extract a major product surface. As we did, the question got wider: How do teams own and deploy frontend surfaces independently without making the product fragmented, the runtime fragile, or the development environment harder to understand?

Module Federation helped us answer part of that question. Most of the work came after.

The monolith had done its job

Monoliths do not deserve the reflexive contempt they sometimes receive in architecture discussions. 

It was the architecture that let the product move quickly before we knew where the durable boundaries were. Decisions stayed close together, the first versions of things were easier to ship, and a lot of distributed-system complexity could stay somebody else's problem until there was enough organizational scale to justify taking it on.

For a long time, that tradeoff worked. Eventually, however, the shape of the organization started pushing against the shape of the code.

Frontend changes shared an application lifecycle with unrelated backend changes. A narrow frontend change could still pay the coordination and build cost of a much larger system. The ownership model encoded in the application no longer matched the ownership model we wanted teams to have. Product teams could own the intent of a surface without always owning the complete path to building, validating, and deploying it.

Extracting frontend surfaces gave us a way to change that incrementally.

Module Federation was the mechanism, not the platform

We chose Webpack Module Federation because it let an existing host load independently built frontend modules at runtime. That mattered because we did not want to rewrite the product shell in order to start changing how individual surfaces were owned and deployed.

A shared package could distribute code, but it would not create an independent deployment lifecycle. A monorepo could improve coordination, but it would not answer what happened when one team wanted to release without another. A design system could help the product feel coherent, but it would not create operational autonomy.

Module Federation gave us a mechanism for composition. Then we had to decide what composition actually meant in production.

How would the host find the right assets? Which dependencies had to share a single runtime instance? What happened if a remote failed to load? Could a team test a change across the host/remote boundary before deploying it? How would rollback work? Could an engineer run the integration locally without first publishing something to a shared environment? Which parts of the host were real contracts, and which were implementation details that remotes should never depend on?

Those questions became the platform.

The first extraction made the abstractions real

The first major proof point was extracting our Publisher frontend from the Rails monolith into a separately built React application. It had to build and test independently, publish assets into multiple environments, load back into the existing application, preserve feature parity, and support a controlled rollout.

By March 2025, we had staging and production builds in place and were loading the extracted application in staging behind a feature flag. At that point, we had proven that the code could move. What followed was learning what had to exist around that code for the separation to hold up.

One of the first questions was how the Rails application would reliably discover the JavaScript and CSS produced by an independent build. We introduced an MFE support layer that retrieves a published manifest, parses its entry points, and emits the appropriate scripts and stylesheets.

That manifest became an intentional contract between a publisher and a consumer.

Once the two systems could deploy independently, the manifest carried more responsibility than its small size suggested. It told the host what had been published, and the host depended on that information being complete and internally consistent. 

That became especially important during deployment. If a new entry point becomes visible before all of its referenced chunks exist, the application can break even though every upload eventually succeeds. The ordering and atomicity of publication were now part of how the application behaved in production.

We retained build history by source revision and added workflows that could restore known entry artifacts. Rollback became part of the publishing model instead of a set of emergency instructions somebody had to remember.

Separate builds still shared one browser

Independent build graphs can make teams feel isolated from one another. The browser has no such illusion.

React, React DOM, our design system, internationalization, and state-management libraries could not safely become accidental duplicates with subtly different runtime behavior. Our shared Webpack configuration therefore treated critical dependencies as singletons and made the responsibilities of hosts and remotes explicit. The host could provide dependencies it needed synchronously, while remotes consumed those shared instances without bundling competing copies.

That was one of the places where the meaning of “independent” became clearer for us. Teams could own different code and release cycles while still participating in the same runtime, and that shared runtime needed rules of its own.

Local development had to cross the boundary too

An architecture becomes difficult to adopt when the only way to test it is to publish something. Our local-development path evolved so that an engineer could run a local Publisher build while using a hosted review version of the Rails application. The browser loads the manifest and chunks from the engineer's machine while the hosted application remains unchanged.

It's the kind of thing that sounds like a small tooling improvement until you don't have it. Without it, integration changes turn into publish-and-wait loops. Debugging takes longer. Environment variables, proxies, cookies, branch overrides, and other bits of setup slowly become knowledge held by whichever people had to solve the problem first.

We did document those things, but we increasingly tried to make the path explain itself. Preflight checks could tell you whether the local manifest was reachable. The loading experience could tell you whether it was fetching the manifest, loading scripts, or waiting for React to mount.

A blank page is a terrible interface for a distributed system. We wanted failures to leave behind enough evidence that somebody unfamiliar with the original migration could tell where the contract had broken.

Deployment independence needed failure containment

Runtime composition also introduced another uncomfortable possibility: a separately deployed application could still take down the page containing it. A remote might be unavailable, expose the wrong module, or load successfully and then throw because the host and remote disagreed about a prop or some other runtime assumption.

We introduced a standard composition pattern with a loading boundary and a non-blocking error boundary. Loading failures and render-time failures were handled separately, errors were logged, and the rest of the page could remain usable if one remote surface failed.

There were limits. React error boundaries do not catch failures in event handlers or arbitrary asynchronous work, so those need their own handling. That distinction mattered because “we wrapped it in an error boundary” can become a comforting sentence long before it becomes a complete failure strategy.

The stronger form of independence was not just that a team could deploy separately. It was that one team's bad deploy was less likely to become everybody's bad deploy.

Adoption revealed the real contracts

The first extraction told us whether the system could work. Other teams using it told us whether we had actually built a platform.

Publisher eventually began composing independently deployed modules from other product areas. CRM-owned modules provided Contacts experiences such as rails, cards, search, page sections, and activity feeds. Commerce exposed modules for the Sell experience, package creation, and invoice composition.

Those integrations taught us more than an empty scaffold ever could. They brought different release cycles, data requirements, ownership models, and UI behavior into the same host, and they exposed coupling that was easy to miss in a diagram.

In one case, CSS added for a Commerce surface remained active after navigation and changed styles in a sibling Contacts surface. The builds were separate; the document wasn't. The eventual fix scoped those styles to their mounted subtree and added regression coverage around the transformation.

That bug stuck with me because it made the browser's opinion of our architecture very clear. Repository boundaries do not isolate JavaScript globals, dependency instances, stylesheets, portals, storage, or the DOM. If we wanted those resources to behave like boundaries existed, we had to create the rules ourselves.

The paved road had to be encoded, not merely documented

We wrote a lot of documentation. It was necessary, and it was also not enough.

A document can tell teams which dependencies should be shared, how assets should be named, how a remote should expose its entry points, and which tests should run. But every important rule that exists only in prose creates another opportunity for somebody to miss it, misunderstand it, copy an old version, or discover that the instructions no longer match reality.

We started turning the original proof of concept into a reusable template. It included a standard project structure, Module Federation configuration, shared dependency setup, local-development support, testing tools, and common build scripts. We also explored generating new MFE projects through our internal scaffolding CLI.

The early generator was intentionally incomplete. It could assemble the template and CI/CD layers, but stayed in dry-run mode while pieces like hosted deployment, dynamic loading, and the local development experience were still evolving. That incompleteness was useful because it made one thing obvious: generating the repository was the easy part.

The path needed to continue through local development, review, deployment, integration, observability, and rollback.

As more teams used the framework, repeated configuration moved into maintained shared packages. Deployment and rollback behavior became reusable. Testing defaults expanded. Local integration became easier to diagnose. Production issues became a source of platform requirements, and whenever we could replace “remember to do this” with a default, check, test, or tool, we tried to.

The platform kept teaching us things after the migration was over

One of the clearest examples came from how manifests were loaded in review environments. An early implementation broadly scanned roughly 50 branches every 30 seconds. It worked, but it also produced around 60,000 identity and storage API requests every hour and close to $1,000 per month in avoidable infrastructure cost. Nobody had asked us to solve that when the original project began; however, once the framework owned the mechanism, every future adopter was going to inherit the cost.

We changed the model to prefetch only explicitly configured manifests and load other branches on demand through the CDN. Loaded manifests could remain cached if the upstream source was temporarily unavailable. That removed the unnecessary cost and eliminated a large amount of background polling.

Performance work produced another version of the same lesson. Fetching only the assets referenced by a manifest looked like the efficient thing to do in CI, but in practice, per-file transfer overhead made it slower than bulk transfer followed by local pruning. Changing the strategy cut that stage's runtime by roughly half to more than two-thirds.

Neither case was particularly glamorous architecture work, but both made the system noticeably better to operate. Platforms benefit from defaults; good defaults come from measurement rather than intuition.

What I would define earlier if we started again

If we were starting again, I would spend more time defining the adoption contract before trying to broaden adoption. A real extraction is important because it forces vague ideas to become concrete, but it also creates a trap: the first use case can quietly become the template for every use case that follows.

It seems obvious in hindsight, but clarifying some questions earlier would have been key. Which modules are intentionally public? How does host context cross the boundary without exposing host internals? Which dependencies have to behave as singletons? Where should data fetching and client-side state live? How are styles and portaled UI contained? How do we find version skew before production does? What happens when a remote fails? Can our telemetry tell us which remote is responsible, or only that the host page broke?

Some of those answers now live in code. Some are still where I think the next version of this work should go: typed module contracts, compatibility checks in CI, per-remote observability, integration smoke tests, safer runtime loading, and clearer expectations around context and data ownership.

A platform tends to keep exposing its missing assumptions as more people use it. The useful part is turning those discoveries into safer defaults for whoever comes next.

Agents changed how I think about the repository boundary

There is another reason I'm reconsidering some of these decisions now: the development environment changed.

Our early microfrontend boundaries were designed around human concerns such as ownership, cognitive load, release autonomy, build isolation, and blast radius. Those concerns are still real. Coding agents add context to the list.

For a genuinely independent change, a smaller repository can be an excellent boundary. The agent has less irrelevant code to search through, the project's tests and conventions are easier to find, and the surface area is narrower.

The problem appears when the repository boundary is cleaner than the system boundary.

A change that looks local to a remote may depend on host props, a shared React singleton, a manifest shape, a Rails helper, a feature flag, a cross-repository test, or the order in which two deployments reach production. The agent may see one repository while the behavior it needs to understand spans several.

Humans have historically been pretty good at filling those gaps. Someone remembers which repository contains the other half of the contract. Someone knows who owns it. Someone remembers which integration suite actually matters.

Agents make that missing context more visible. If the repository becomes a context boundary, then the knowledge required to safely cross it needs somewhere else to live.

That has made me reconsider what a repository boundary means.

A monorepo could make more of the system searchable at once and simplify cross-cutting refactors, dependency upgrades, and test selection. It could also provide an agent with an enormous amount of irrelevant code, and putting code in one repository would not change the fact that the browser still shares CSS, JavaScript globals, the DOM, storage, and runtime dependencies.

So I've become less interested in answering “microfrontends or frontend monolith?” as a single architectural question. I'm more interested in whether the boundaries we have still help us understand and change the system safely. Some probably do. Some may have outlived the conditions that created them.

Separate repositories raise the bar for contracts and tooling. Agents need deterministic setup, explicit interfaces, reliable ways to find related code, and tests that understand when a supposedly local change crosses a system boundary. A larger repository raises a different bar: the structure needs to help both people and agents discover the relevant context without making everything feel equally important.

Either way, assumptions that currently live in organizational memory need somewhere more durable to go: into code, contracts, tests, and documentation that can actually be found at the moment somebody needs it. The original extraction solved a real constraint and taught us where much of the coupling actually lived. I don't see revisiting the topology as undoing that work. I see it as using what the system taught us.

What I care about now

The thing I care about most is not whether the architecture diagram looks cleaner. It's whether someone who wasn't there for the original migration can change the system without reconstructing the history of why it works the way it does.

Can they run it locally? Can they see where it failed? Can they understand which side of the boundary owns what? Can they roll it back without finding the person who remembers how?

Every time the answer was “you need to know who to ask,” we had found another part of the platform that wasn't finished yet.

That feels increasingly relevant as agents become part of the development environment. People have always carried an enormous amount of missing context through organizations, and software is less forgiving when important assumptions exist only in somebody's head.

The repositories may stay separate, and some may eventually come back together. I'm less attached to that particular shape than I once was. What I want to preserve is the independence we were trying to create, while making the knowledge required to use that independence much easier to find.

Ideally, the next set of developers can come to this work, understand the conditions and tradeoffs that shaped the decisions we made, and have enough context to decide whether those decisions still make sense for the system they inherited. I’d consider that a success even if they eventually make different choices.

Appreciation

This work crossed frontend platform, product engineering, quality engineering, infrastructure, developer experience, and the teams adopting the framework. Thank you to everyone who tested the extraction, challenged the contracts, built the publishing and rollback paths, improved local development, and helped turn the awkward production lessons into better defaults for the teams that followed.