DEV Community

Cover image for Microfrontends Are Deployment Boundaries
Viktor Lázár
Viktor Lázár

Posted on

Microfrontends Are Deployment Boundaries

Why independent deployment is the real boundary, why build-time composition keeps teams coupled, and why projection is a better primitive for loose microfrontends.

Microfrontends are usually sold as an organizational architecture. Several teams own different parts of the product. Each team can move at its own speed. The checkout team ships checkout. The search team ships search. The account team ships account. The host application stitches those pieces together into something that feels like one product.

That story is attractive because it maps cleanly to how large products are actually built. Teams already have ownership boundaries. Domains already have different cadences. The frontend is usually the place where all of that independence gets flattened into one visible surface, so it is natural to ask whether the frontend can preserve the same autonomy the backend learned to preserve years ago.

But the word "microfrontend" has become loose enough to hide the important question. A team can have its own folder, its own package, its own repository, and still be waiting on the same release train as everyone else. The useful question is not where the code starts. It is where the change is allowed to finish: can one team change and deploy its part of the user interface without forcing the whole application to be rebuilt, retested, and redeployed? If the answer is no, the architecture may be modular, but it is not independent. It is a monolith with microfrontend-shaped furniture inside it.

The Microfrontend-Shaped Monolith

The most common failed microfrontend architecture begins with a reasonable compromise. The product is too large for one team to own comfortably, so the frontend is split into packages. The search team owns @company/search. The checkout team owns @company/checkout. The account team owns @company/account. Each package has its own tests, its own maintainers, maybe even its own release process. The host app imports them all.

At first, this feels like progress because the source tree now matches the org chart. Code review gets cleaner. Ownership gets clearer. Teams stop stepping on each other in the same directory. But then the first ordinary production change arrives: checkout needs to rename a field, hide an experiment, or fix a validation message. The package changes, the package version increments, and the host still has to consume that version before any user can see it.

The deploy boundary has not moved. The host still produces one application artifact. Every package is resolved at build time. Every team contribution flows into the same bundle graph. A tiny text change in checkout still becomes a new version of checkout, then a host dependency update, then a host build, then a full application deployment. The team did not remove the release train. It only gave each carriage a name.

That distinction matters because deployment is where organizational autonomy becomes real. Source ownership is useful. Package ownership is useful. Code review ownership is useful. But a team that cannot deploy independently is still coupled to the teams around it at the moment that matters most: the moment users receive the change.

You see the problem most clearly in the release meeting. Checkout has a harmless copy fix ready. Search has a larger experiment that needs one more day. Account has a production bug that should go out immediately. If all three changes flow through the same host artifact, the organization has to choose between waiting, splitting the release manually, or shipping unrelated risk together. Rollback becomes a product-wide decision because the artifact is product-wide. CI becomes slower because the final build has to prove the entire application again. The host team becomes an integration bottleneck. Eventually the organization starts saying things like "the microfrontend platform is slow," when the deeper truth is that the microfrontends are still sharing one deployment fate. That is not a microfrontend problem so much as a boundary problem.

Build-Time Composition Is Still Tight Coupling

Build-time composition has a clean mental model: import the thing, bundle the thing, ship the thing. It works beautifully for ordinary component libraries. A design system should probably be a package. A date picker should probably be a package. Shared formatting utilities should probably be packages. The application wants those dependencies to be part of its own build because they are not independent products. They are materials the product is built from.

A microfrontend is different because it is not just code reused by the host. It is a separately owned application capability that happens to appear inside a larger product surface. It has its own release pressure, its own incidents, its own product owner, and often its own backend assumptions. Treating that as a package pulls the boundary back into the host.

The host has to know the package name. It has to know the exported component. It has to know which version it wants. It has to rebuild when that version changes. It has to carry the remote's framework compatibility, transitive dependencies, styling assumptions, and initialization shape into its own build pipeline. That can be the right trade for shared UI, but it is a poor trade for independent product surfaces.

The mistake is subtle because the code looks decoupled. The checkout package has its own folder. The package has its own package.json. The team can publish a new version whenever it wants. But the user cannot receive that version until the host consumes it. The real dependency is not in the repository. It is in the deployment graph.

You can draw the real architecture by following the change instead of following the code. In this version, the change still flows from the remote package into a host dependency update, then into a host build, then into a full application deployment. That path is the monolith. The fact that the interior has been split into packages does not change the shape of the release.

Build-time microfrontend coupling

Runtime Composition Moves the Boundary

A loose microfrontend architecture changes the graph. The same checkout copy fix now has a different path through the system: the checkout team changes checkout, tests checkout, deploys checkout, and the host continues to point at the checkout surface it already knows how to compose. The host does not import the remote at build time. The host composes the remote at runtime. The remote team deploys its own artifact. When the remote changes, the next user receives the new remote without the host being rebuilt.

The path becomes shorter: remote change, remote deployment, runtime composition by the host. That is the architectural shift: the deploy unit has become smaller than the product. A checkout fix can deploy checkout. A dashboard change can deploy the dashboard. A search experiment can deploy search. The host remains responsible for the shell, routing, navigation, authentication posture, layout rules, and cross-application contracts. It is no longer responsible for producing every piece of application code that appears on the page.

Loose microfrontend runtime composition

This is what people usually wanted when they reached for microfrontends in the first place. Not separate repositories for their own sake, not more bundler configuration, not a new way to argue about shared dependencies, but independent change. Runtime composition is the technical mechanism that makes that independence visible to users.

Loose Means the Host Knows Less

Moving composition to runtime is necessary, but it is not sufficient. A runtime-composed system can still be tightly coupled if the host knows too much about the remote. If the host has to know the remote's framework, exported module names, internal route structure, shared dependency versions, state manager, styling strategy, initialization API, and rendering lifecycle, then the host and remote are still negotiating as one system. They are only doing it later.

Loose coupling means the host's knowledge should be intentionally small. The host should know where the remote lives. It may know which region of the remote it wants. It may pass a small amount of explicit state through a documented channel. It may provide theme tokens, size constraints, navigation affordances, or authentication context through a boundary designed for that purpose.

But the host should not need to know how the remote is built. It should not care whether the remote is React, Vue, Svelte, Solid, Angular, or plain HTML. It should not care whether the remote was bundled with Vite, webpack, Rollup, or a framework-specific compiler. It should not care whether the remote upgraded React yesterday. It should not share a global CSS cascade with the remote by accident. It should not receive a remote bug because both sides silently agreed on a singleton dependency neither team fully owns.

The best microfrontend boundary feels more like the web than like a package manager. A URL is a stronger independence boundary than an import. An explicit message is a stronger state boundary than shared runtime context. A theme token is a stronger visual boundary than a leaked stylesheet. A projected region is a stronger composition boundary than a federated component export. The less the host knows, the more the remote can change.

Where Module Federation Helps, and Where It Still Couples

Module Federation was an important step because it made separate builds visible in mainstream frontend architecture. A host could load remote modules at runtime. A remote could expose a module. Multiple builds could participate in one application without being compiled into one artifact up front.

That solved a real problem, especially for teams that were stuck between one bundle and a full <iframe> embed. It gave them a way to split the build while keeping the experience inside one application. What it did not do was remove every coupling problem.

In a typical federated system, the host still consumes a named remote module. The host configuration contains the remote identity and entry point. The host imports something shaped like remote/Button or checkout/App. The remote exposes modules by name. Both sides coordinate shared dependencies through share scopes and version rules. The official Module Federation configuration surface reflects this: remotes defines what the consumer can load, and shareScope defines which shared dependency pools the host aligns with a remote.

That is a runtime architecture, but it is still a module architecture. The unit of composition is a JavaScript module. The host is still written against the remote's exported surface. The remote is still participating in the host's runtime dependency graph. Shared dependencies are still a negotiation. Framework compatibility still matters. The failure modes still look like runtime versions of build-time coupling: missing exposed modules, mismatched shared libraries, incorrect public paths, container initialization problems, or a host that has to understand too much about how a remote wants to boot.

None of this makes Module Federation bad. It makes it specific. Module Federation is a good fit when the remote really is a module the host should import. It is useful when teams share a runtime family, when the exported surface is intentionally stable, when dependency sharing is a feature rather than a liability, and when the host is expected to compose code rather than documents.

But the loosest microfrontend boundary is not code composition. It is application composition. The remote should be allowed to remain an application. It should have its own router, runtime, styles, state, effects, and deployment. The host should not have to turn it into a component before it can place it on the page. This is where projection becomes interesting.

The Rectangle Was The Wrong Compromise

The web already has an ancient primitive for independently deployed application composition: the <iframe> element. An <iframe> is excellent at isolation. It gives the remote its own browsing context. The remote can run its own JavaScript, load its own CSS, keep its own globals, and live behind the browser's origin boundary. The host does not need to know the remote's framework or dependencies. The remote is a document at a URL.

The bad part is the rectangle. An <iframe> does not naturally participate in the host layout. It is visually and interactively boxed off. It fights sizing. It complicates focus. It does not inherit the host's theme in the way a native element would. It is awkward for accessibility. It feels like an embed because it is an embed.

So teams often flee from <iframe> elements back to shared bundles. Shared bundles give composition back. The remote can appear like a component. It can flow in the layout. It can use the same design system. It can participate in the same DOM. But the price is isolation: now the remote shares the host's JavaScript world, CSS world, dependency world, and failure world. The rectangle is gone, but so is the clean boundary that made the <iframe> attractive.

This has been the uncomfortable trade: <iframe> elements preserve isolation but compose poorly; shared bundles compose well but collapse isolation. That trade is why projection matters. It is an attempt to keep the remote's application boundary without accepting the rectangular <iframe> surface as the visible shape of the integration.

Microfrontend composition tradeoff space

Projection As The Missing Primitive

Virtual Frame is built around a simple idea: let the remote stay an application, but project its live DOM into the host as if it were part of the host layout.

The remote still loads in a hidden <iframe>, but the <iframe> is not the composition surface. It is the remote's runtime environment. That distinction matters because the remote's framework, router, scripts, effects, state, and browser APIs keep working normally inside a browsing context the host does not own. Virtual Frame does not re-execute the remote as host code. It observes the remote, then projects the visible result somewhere else.

Then it mirrors the remote's DOM into a host element. Events on the mirrored DOM are replayed back to the source. Input values stay synchronized. Styles are rewritten so the projected content can live inside a Shadow DOM boundary. CSS custom properties can still cross that boundary, which means the host can theme the projection without sharing the entire cascade. For cross-origin remotes, a small bridge script serializes snapshots and mutations over postMessage, so the host can reconstruct the projected tree without same-origin DOM access.

The result sits between the two old choices. The remote keeps the isolation shape of an <iframe>, while the host gets something that composes more like DOM. That is exactly the shape loose microfrontends want: the remote can keep its own world, and the host can still build a coherent page.

It also matters that projection does not have to mean "blank until the remote loads." The <iframe> is needed as the remote's client-side execution environment, but the first projection can still be produced on the server. Virtual Frame has an SSR path where the host server can fetch the remote document, inline the projected HTML inside declarative Shadow DOM, and let the client resume from that markup. The client can then recreate the runtime environment for interactivity instead of making the user-visible composition wait for a fresh <iframe> round trip. For architecture, this is important: loose runtime composition should not automatically mean worse first paint, worse crawlers, or a permanent loading rectangle where the remote should be.

The host does not import the remote's component. It points at the remote's URL. The remote does not become part of the host's module graph; it remains a deployed web application. The host does not need the remote's React version. It needs a browser document. The remote does not need to expose a federated module. It needs to render the UI it already owns.

The contract moves from "give me a JavaScript module with this name" to "render this application, and I will project the part of the document I need." That is a much looser contract.

Selector Projection Makes The Boundary Practical

Whole-page projection is useful, but most product shells do not want the whole remote page. They want a panel, a chart, a search result region, a checkout step, a settings form, a notification center, or some other domain-owned island inside a larger layout. This is where selector projection matters.

Virtual Frame can project a single subtree from the remote document using a CSS selector:

<virtual-frame
  src="https://checkout.example.com/cart"
  isolate="open"
  selector="[data-region='cart-summary']"
></virtual-frame>
Enter fullscreen mode Exit fullscreen mode

The remote still runs as a complete document. Its router still works. Its data loading still works. Its app-level providers still exist. Its stylesheets are still collected and applied. But the host only receives the selected region. That is a subtle but important distinction.

A package-based microfrontend asks the remote team to expose the thing the host wants as a component. That sounds reasonable until the component depends on page-level providers, router state, feature flags, data loaders, ambient CSS, or lifecycle assumptions that were never designed to be exported. The team either extracts a component and recreates its environment in the host, or it leaks more of the remote's internals across the boundary.

Projection inverts the problem. The remote renders itself in its native environment, and the host selects the region it wants from the result.

The stable contract can be as small as a URL and a selector:

src:      https://checkout.example.com/cart
selector: [data-region='cart-summary']
Enter fullscreen mode Exit fullscreen mode

That selector is not an implementation detail in the bad sense. It is a product integration contract. It is the remote saying: "this region is safe to project." The host does not need to know whether that region is implemented by one component, twelve components, a server-rendered template, a client-rendered app, or a future rewrite in another framework.

The remote can change everything behind the selected node and keep the contract. That is what a loose boundary looks like.

Deployment Becomes A Local Event

Once the host composes by URL and projection, a remote deploy can be local again. The checkout team changes the cart summary. They deploy checkout. The host still points at the same URL and selector. The next time the host page renders, it receives the new cart summary. No host dependency update. No host rebuild. No global release train.

This changes the risk model because the release now follows the ownership boundary. In the package model, even a local UI change becomes a global deployment event because the host artifact changes. In the projection model, the remote artifact changes and the host composition stays stable. A rollback is a remote rollback. A canary is a remote canary. An experiment is a remote experiment. The blast radius is closer to the ownership boundary.

It also changes the architecture review. The question stops being "how do we share this code safely?" and becomes "what is the smallest stable browser-level contract between these applications?"

That question leads to better boundaries:

  • A stable URL for the remote capability.
  • A stable selector for the projected region.
  • CSS custom properties for theme.
  • Explicit messages or a typed shared store for cross-boundary state.
  • Navigation events or URL changes for routing.
  • Shadow DOM isolation for style containment.
  • SSR projection when first paint or SEO matters.

Each contract is visible. Each one has a purpose. None of them require the host and remote to pretend they are one JavaScript application.

The Host Should Be A Place, Not A Parent

Many microfrontend designs accidentally turn the host into a parent application. The host owns the runtime. The host owns the dependency graph. The host owns the root framework instance. The host owns global state. The host owns the route tree. The host loads child applications as modules and gives them a place to mount.

That can work, but it creates a hierarchy that is often stronger than the organization wants. The remote teams are not truly peers. They are plugins to the host. A looser design treats the host as a place.

The host owns the product shell: identity, navigation, layout, page-level composition, coarse permissions, and the integration contracts. It creates places where domain applications can appear. Those applications are not children in the host's runtime. They are independently deployed web applications projected into those places.

That shift sounds philosophical, but it has practical consequences. The host no longer needs to be upgraded when a remote changes framework versions. The remote no longer waits for the host to accept a dependency update. The design system can move through CSS custom properties and documented visual contracts instead of a forced singleton package. Cross-application state can move through explicit channels instead of shared in-memory context. The host can remain small because it is not the place where every team's implementation becomes one dependency graph. The host becomes a composition surface, not an owner of everything inside it.

What Projection Does Not Remove

Loose does not mean lawless. Projection gives teams a better boundary, but the boundary still needs contracts. A projected region should have stable selectors. The remote should treat those selectors as public API. CSS custom properties should be named and versioned with care. Cross-boundary state should be explicit. Error states should be designed. Loading states should be designed. Observability should identify which remote is failing. Security reviews should understand that projected DOM is visible to the host, so this is not a hard confidentiality boundary from the host side.

Virtual Frame also does not make every microfrontend problem disappear. If two pieces of UI need deep synchronous access to the same framework state, they probably are not two applications. If the host and remote already share one build and one release process by design, a package or Module Federation may be lighter. If the remote is untrusted and its rendered content must remain opaque to the host, a visible <iframe> is still the right primitive. If the content is static and non-interactive, server-side includes or ordinary HTML fragments may be enough.

The point is not that projection replaces every integration style. The point is that projection fills a gap the frontend has been stuck with for too long: independently deployed applications that need to compose like part of the interface, not like embeds and not like shared bundles.

The Smaller Point

The promise of microfrontends was never that the frontend would contain more projects. The promise was that product teams could move independently without breaking the illusion of one product.

That promise lives or dies at the deploy boundary. If every change still flows through one host build and one application deployment, the architecture has not bought independence. It has bought modularity, and modularity is useful, but it is not the same thing.

Runtime composition moves the deploy boundary to the right place. Loose runtime composition moves the knowledge boundary there too. The host should know less. The remote should remain an application. The contract should be browser-native wherever possible: URLs, DOM, CSS, events, messages. Code sharing should be a choice, not the price of composition.

Module Federation moved the ecosystem toward runtime composition, but its center of gravity is still the JavaScript module. For many teams, that is exactly enough. For the loosest version of microfrontends, the unit needs to be the deployed application, not the exported component.

Projection is the primitive that makes that possible. It keeps the remote alive in its own world and lets the host compose the part the product needs. The <iframe> remains an execution environment for the remote, not the user-facing unit of layout, and the projection can even be server-rendered before the client resumes it. That preserves the deployment independence that microfrontends were supposed to create, without accepting the rectangular <iframe> surface as the final shape of application composition.

That is why Virtual Frame matters. Not because it is another way to embed a page, but because it gives loose microfrontends a boundary that finally matches the architecture they were trying to become.

Top comments (0)