← Volver al blog

Microfrontends: when they make sense and when they don

An honest analysis of microfrontend architecture: integration patterns, real costs, and the criteria I use to decide whether to adopt them on a project.

Microfrontends: when they make sense and when they don't

The word "microfrontends" generates strong reactions. Some teams adopt it as the solution to all their scaling problems; others dismiss it as over-engineering. Both stances are usually wrong.

This article isn't a sales pitch for the architecture, nor a takedown. I'll explain how it works, what problems it actually solves, and the criteria I apply when I have to make that call on a project.


What is a microfrontend

The idea comes directly from the backend world: if microservices split an application into independently deployable services, microfrontends do the same thing to the UI.

Each microfrontend is an autonomous UI unit: it has its own repository, its own build process, its own deployment cycle, and — crucially — it can be written in a different framework than the rest of the application.

A composed application might look like this:

┌─────────────────────────────────────────────┐
│              Shell / Host app               │
├──────────────┬──────────────┬───────────────┤
│ MFE: Catalog │  MFE: Cart   │ MFE: Checkout │
│  (Vue 3)     │  (React)     │  (Vue 3)      │
└──────────────┴──────────────┴───────────────┘

The shell is the container application that loads, mounts, and manages communication between microfrontends. The MFEs are independent of each other.


Integration patterns

There are several ways to compose microfrontends. Each has very different trade-offs.

Module Federation (Webpack / Rspack)

The most widely used pattern in production today. Module Federation allows one application to expose modules and another to consume them at runtime, without bundling them together.

// webpack.config.js — MFE: Catalog
module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: 'catalog',
      filename: 'remoteEntry.js',
      exposes: {
        './ProductList': './src/components/ProductList.vue',
      },
      shared: {
        vue: { singleton: true, requiredVersion: '^3.0.0' },
      },
    }),
  ],
}
// webpack.config.js — Shell
module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: 'shell',
      remotes: {
        catalog: 'catalog@https://catalog.example.com/remoteEntry.js',
      },
    }),
  ],
}

And in the shell, consuming the remote component is as simple as:

// Runtime lazy load
const ProductList = defineAsyncComponent(() =>
  import('catalog/ProductList')
)

The shared key is fundamental: if Vue is not marked as singleton, you'll end up with two Vue instances on the same page, with all the problems that entails.

Web Components

Each MFE exposes itself as a Custom Element. The shell uses them with standard HTML.

// In the MFE: register the component
class CatalogWidget extends HTMLElement {
  private _app: ReturnType<typeof createApp> | null = null

  connectedCallback() {
    this._app = createApp(App)
    this._app.mount(this)
  }

  disconnectedCallback() {
    this._app?.unmount()
    this._app = null
  }
}

customElements.define('mfe-catalog', CatalogWidget)
<!-- In the shell -->
<mfe-catalog data-category="sneakers" />

Advantage: framework-agnostic, the shell neither knows nor cares what's inside.
Disadvantage: communication via DOM attributes and events is more limited, and Shadow DOM complicates sharing global styles.

Iframes

The oldest approach and the most isolated. Each MFE lives in its own iframe. Communication happens via postMessage.

// Shell → MFE
const iframe = document.querySelector<HTMLIFrameElement>('#mfe-cart')
// Always specify the exact origin — never '*' in production
iframe?.contentWindow?.postMessage({ type: 'ADD_ITEM', payload: item }, 'https://cart.example.com')

// MFE listening — always validate the origin
window.addEventListener('message', (event) => {
  if (event.origin !== 'https://shell.example.com') return
  if (event.data.type === 'ADD_ITEM') {
    store.addItem(event.data.payload)
  }
})

Isolation is total — CSS, JS, execution context — but performance and UX suffer. Iframes have their own scroll contexts, their own history, and accessibility is complex to manage. I'd only use them for integrating legacy applications I can't touch.


The real cost nobody mentions

Architecture articles tend to focus on benefits. These are the costs I actually encounter in practice:

1. Network overhead

Each MFE loads its own runtime. If the shell uses Vue 3 and the cart MFE also uses Vue 3 but without shared properly configured, the user downloads Vue twice. With five MFEs that scales poorly.

The solution is shared dependencies, but managing them correctly — with compatible versions across teams that evolve independently — is a constant source of conflict.

2. Shared state

How do two MFEs that don't know each other share state? The options are:

  • URL as source of truth — state lives in query params or fragments
  • Custom DOM events — the shell acts as an event bus
  • Shared store — only works if both MFEs use the same framework and version

None of these is as clean as a normal reactive store inside a SPA.

3. Fragmented developer experience

A well-configured monorepo mitigates this, but it's still more complex than a SPA. Developers need to spin up multiple projects locally, manage API contract versions between teams, and coordinate changes that affect the shell.

4. Visual consistency

If every team is free to choose their own components, the design system fragments. The solution — a shared component package — introduces another dependency to version, which can become a bottleneck.


When I adopt them (and when I don't)

After several projects with this architecture, I've settled on a fairly clear criterion.

Makes sense when:

  • There are multiple autonomous teams that need to deploy independently without constant coordination
  • The application has areas with very different release cadences (checkout deploys 10 times a day; the CMS, once a month)
  • You're migrating a legacy application and can't rewrite everything at once
  • The organization already has backend microservices and teams are aligned around business domains

Doesn't make sense when:

  • There's a single team — the operational cost exceeds any benefit
  • The application is small or medium — a well-structured SPA with lazy loading does the same job
  • It's being adopted for architectural fashion rather than a concrete problem to solve
  • The team has no experience managing distributed infrastructure

The question I always ask first: what specific problem in my organization does this solve? If the answer is vague, the answer is no.


An alternative that usually works better

Before jumping to microfrontends, I always evaluate a modular monolith with aggressive lazy loading:

// router/index.ts — Vue Router with separate chunks
const routes = [
  {
    path: '/catalog',
    component: () => import('@/modules/catalog/CatalogView.vue'),
  },
  {
    path: '/cart',
    component: () => import('@/modules/cart/CartView.vue'),
  },
  {
    path: '/checkout',
    component: () => import('@/modules/checkout/CheckoutView.vue'),
  },
]

With Vite and code splitting, each module generates its own chunk. Users don't download checkout code until they need it. Teams can work on independent modules within the same repository. Shared state is trivial.

This approach solves 80% of the problems people try to solve with microfrontends, at 20% of the operational complexity.


The problem nobody mentions first: routing

Shared state is hard. Routing is harder. Microfrontend articles tend to skip it.

Who controls the URL? The shell or each MFE? The options are:

Shell-driven routing: the shell decides which MFE to mount based on the current route, and each MFE receives its sub-path as a prop. Works well when MFEs are page fragments — a widget, a side panel.

Distributed routing: each MFE has its own router and manages its URL segment. More autonomy, but you need to synchronize the browser history across applications. With Vue Router you can use createMemoryHistory() in MFEs and leave createWebHistory() to the shell:

// In the MFE — memory mode, no URL ownership
const router = createRouter({
  history: createMemoryHistory(),
  routes: [...]
})

// Shell communicates route changes to the MFE via custom event
window.dispatchEvent(new CustomEvent('mfe:navigate', { detail: { path: '/checkout/confirmation' } }))

There's no clean solution. If a project reaches this level of routing complexity between MFEs, it's usually a sign that the business domain boundaries aren't well defined.

Module Federation also works with Vite

The examples above use Webpack because that's where Module Federation originated (Webpack 5, 2020). If you use Vite, there are two options:

  • @originjs/vite-plugin-federation: a community implementation of Module Federation for Vite. Mature, used in production.
  • Rspack: a Webpack-compatible bundler with native Module Federation and speed comparable to Vite. The option I'd choose for new projects with MFEs.
// vite.config.ts — with vite-plugin-federation
import federation from '@originjs/vite-plugin-federation'

export default defineConfig({
  plugins: [
    federation({
      name: 'catalog',
      filename: 'remoteEntry.js',
      exposes: {
        './ProductList': './src/components/ProductList.vue',
      },
      shared: ['vue'],
    }),
  ],
  build: { target: 'esnext' },
})

The API is nearly identical to Webpack's. The relevant difference: build.target: 'esnext' is mandatory — federated modules use ES dynamic imports and won't work if the build targets CommonJS.


Conclusion

Microfrontends are a valid tool for large organizations with multiple teams and well-defined domains. In that context, Module Federation is the most mature and pragmatic pattern available today.

Outside that context, they're over-engineering. And over-engineering has a real cost: more time on infrastructure, less on product.

As with almost everything in software architecture: the right answer depends on the specific problem, not on what's trending on Twitter.

If you're evaluating this decision on your project and want to talk it through, reach me at hola@miguel-jimenez.dev.