Back to blog

Microfrontends Beyond the Buzzword

Where Module Federation and single-spa fit, what independent deployment costs, and when a simpler frontend is the better choice.

On this page

A remote module loading successfully is an encouraging first milestone. It says much less about whether several teams can safely build one product together.

My production experience has primarily been with Webpack Module Federation in a business platform with a host and independently owned frontend modules. The difficult questions were usually about boundaries: which dependencies to share, who owns navigation, how authentication reaches a remote, and how to identify the version responsible for a production failure.

This article compares that experience with single-spa's documented application model. I am not presenting single-spa as a platform I have operated in production. The comparison is useful because the two tools address different parts of the same problem.

Start with the team boundary

Imagine a business application containing transactions, billing, user management, and settings. Initially, one frontend and one release process may be entirely appropriate. Clear modules inside one repository can carry a product a long way.

As separate teams take responsibility for those areas, coordinating every release can become expensive. Microfrontends offer a way to compose a product from smaller frontend units with clearer ownership and, where the deployment architecture supports it, separate releases.

The user still expects one application. They should not need to understand team boundaries to navigate, sign in, or recover from an error.

That creates the central trade-off: less release coupling between teams, but more work to keep independently changing pieces compatible. Splitting repositories alone does not resolve it. Shared runtime state, tightly coupled APIs, or unclear release rules can leave the teams just as dependent on one another.

Module Federation loads code; single-spa coordinates applications

Module Federation lets independently built applications provide and consume modules at runtime. A host can load a remote entry and access an exposed component or application without compiling that remote's implementation into its own build. The exposed unit might be a full application or a smaller widget. See the Webpack Module Federation overview.

That can support separate deployments, provided the host can discover the intended remote and their interfaces remain compatible. It does not define the application's route ownership or a standard mount/unmount lifecycle. Those decisions remain with the surrounding architecture.

single-spa registers applications with loading functions and activation conditions. It coordinates their bootstrap, mount, and unmount lifecycles. URL-based activation is a common model, and several applications can be active together. An application's unmount implementation is responsible for cleaning up its UI and resources. See single-spa's application lifecycle documentation.

For example, a root configuration could activate a billing application under /billing, while keeping navigation mounted. That is useful for applications with separate histories, including migrations where different frameworks need to coexist.

These responsibilities can be combined:

Code
single-spa activation rule
    → application loader obtains a federated module
    → module supplies application lifecycle functions
    → single-spa mounts the application
    → application cleans up when unmounted

The integration still needs to be implemented; loading an arbitrary component does not make it a single-spa application. The single-spa setup guide discusses Module Federation alongside other loading approaches. A host that already handles navigation and lifecycle well may not need another orchestration layer.

Difficulty one: shared dependencies are contracts

A host and its remotes may all use React, but “they use React” is not a sufficient compatibility contract.

Consider a host on React 18 and an older remote built around React 16 assumptions. Choosing one runtime does not make every API, renderer interaction, or library expectation compatible. Loading separate runtimes also requires a deliberate boundary; components and context cannot simply be treated as interchangeable across them.

Webpack's singleton option permits one version of a shared module within a share scope. requiredVersion expresses a version requirement, while strictVersion controls rejection of invalid versions in the applicable configuration. These settings govern resolution; they do not adapt incompatible code. The exact fallback and error behaviour depends on the configuration. See the ModuleFederationPlugin reference.

I would make the compatibility policy explicit before expanding the number of remotes:

  • Which dependencies must share a runtime, and which can remain private?
  • Which host and remote versions are supported together?
  • What happens when a required version cannot be satisfied?
  • How do teams preview, migrate, and roll back changes?

The same reasoning applies to authentication payloads, permission interfaces, events, and platform SDKs. A remote can deploy independently only within the contracts it still shares with consumers.

In my platform work, copying common code into each application made those contracts harder to maintain. Fixes arrived at different times, and synchronization became its own workload. Versioned shared capabilities gave us a clearer migration path, though they did not eliminate migration work. I cover that experience in Stop AI-syncing the starter.

Difficulty two: routing and lifecycle need an owner

A billing application needs to know its route, but it should not have to guess which parts of the URL belong to the shell or another product area.

A useful starting agreement is that the shell owns top-level navigation and an application owns routes beneath an assigned prefix. The details still need testing: direct links, browser back and forward, redirects, authentication expiry, and what happens when a remote fails to load.

Lifecycle matters just as much. Leaving a route should not leave subscriptions, event listeners, timers, or DOM nodes behind. An orchestrator can call an unmount function; the application must perform the cleanup. Federation can deliver a module; the host still needs a way to start and stop whatever it loads.

Cross-application communication deserves equally narrow boundaries. A change to the selected account or locale may matter to several applications, but sharing every application's internal store creates coupling that separate repositories cannot undo.

I prefer explicit interfaces: a URL parameter where appropriate, a platform API, or a documented event with an owner and a defined payload. Authentication and permissions need a consistent platform contract, with authorization enforced by backend services rather than trusted solely to frontend checks.

The practical question is whether a team can change its own implementation without needing to understand another remote's internal state.

Difficulty three: failures need enough context

A production report saying “the page failed” is difficult to investigate when the host and remotes have separate releases.

Useful error context includes the host version, remote name and version, route, release identifier, relevant feature flags, and a trace ID where available. Account or tenant context should be limited to what is necessary and appropriate for diagnosis.

In my work with Module Federation, observability became part of the platform contract. Following a failure through frontend telemetry and backend spans was more useful than assuming the error belonged to whichever repository first displayed it.

Independent deployment does not automatically create failure isolation. Code sharing the same page can still interfere through global state, styles, or expensive work on the main thread. React error boundaries help with some rendering failures; they are not a sandbox for all remote behaviour.

A platform needs deliberate loading fallbacks, error handling, version visibility, and a recovery path. It also needs integration checks. A remote's unit tests can pass while the remote fails inside its actual host.

Previewing a remote against a supported host, testing critical contracts, and running a small set of end-to-end checks are more informative than treating successful compilation as proof of compatibility.

Make the platform usable for its teams

The architecture also has to work on a developer's machine. If every small change requires assembling many repositories, authentication services, and backend dependencies, release independence may have moved the coordination cost into local development.

Useful platform support includes scaffolding, documented configuration, local mocks, preview deployments, and a way to choose which remote version a host loads. Shared design primitives and localization conventions help the composed product stay coherent without forcing every team to recreate basic behaviour.

Someone also needs to own compatibility policy, release guidance, and incident coordination. Owning a remote includes what happens after deployment. I discuss that responsibility more personally in Ownership Before Title.

When I would choose a simpler frontend

I would hesitate to introduce microfrontends when a small team already releases quickly, the application remains understandable, or domain boundaries are unclear. A modular application can provide strong internal boundaries without runtime composition and separate deployment infrastructure.

My decision would start with the constraint:

  • Runtime module delivery and dependency sharing: consider Module Federation.
  • Application activation and lifecycle coordination: consider single-spa.
  • Both problems are real: evaluate a combined architecture, including its integration cost.
  • Neither is blocking delivery: keep the simpler application.

The measure is whether clearer ownership and separate releases justify the operational work. For me, the most useful lesson from microfrontends has been that loading the code is only the beginning. Compatibility, navigation, diagnostics, and support determine whether teams can actually work independently while delivering one coherent product.