Asia/Dhaka
Posts

Building Resilient Micro-Frontends

November 3, 2024
As web applications grow, the frontend often becomes a massive, unwieldy monolith. Dozens of developers working in the same React or Angular repository inevitably step on each other's toes. A single bug in the shopping cart might crash the entire application, preventing users from even logging in. Deploying a tiny CSS fix requires running the entire CI/CD pipeline and deploying the full multi-megabyte bundle. Backend engineering solved this problem years ago with Microservices. Now, the frontend is catching up with Micro-frontends. The symptoms of a frontend monolith outgrowing its team are usually easy to spot:
  • Merge conflicts as a way of life. Every team touches the same App.tsx, the same routing config, the same global state store.
  • A build pipeline that takes 20+ minutes. As the codebase grows, so does the bundle graph, and CI time grows with it.
  • Coordinated release trains. Nobody can ship independently — every feature waits for a shared release date because everything is bundled together.
  • Fear of touching shared code. Nobody wants to refactor a shared utility because it might break three other teams' features.
If any of this sounds familiar, it's worth asking whether the problem is technical debt — or an architecture that no longer matches the size of the organization. A micro-frontend architecture breaks a monolithic frontend application into smaller, independent, and deployable pieces. Each piece (a micro-frontend) is typically owned by a single, cross-functional team, and each one can be built, tested, and deployed on its own schedule. For example, an e-commerce site might be split into:
  1. Product Search Team: Owns the search bar, filtering UI, and search results page.
  2. Product Details Team: Owns the individual product page, reviews, and image galleries.
  3. Checkout Team: Owns the shopping cart and payment flow.
These teams can choose their own tech stack (Team A uses React, Team B uses Vue — though sticking to one framework is generally recommended for performance), manage their own CI/CD pipelines, and deploy their features independently without coordinating with the other teams. This mirrors Conway's Law: the structure of your software tends to mirror the structure of the organization that builds it. Micro-frontends make that mapping explicit instead of fighting it.
How do you stitch these separate applications together into a cohesive user experience? There are three broad strategies, and most real systems end up combining more than one. The server assembles the HTML fragments from different micro-frontends before sending the final page to the browser, often using Edge Side Includes or a templating layer like Tailor.js or Podium.
Html
<!-- Simplified server template -->
<html>
  <body>
    <esi:include src="https://search.internal/fragment" />
    <esi:include src="https://cart.internal/fragment" />
  </body>
</html>
This provides excellent initial load performance and is SEO-friendly, since the browser receives fully composed HTML. The tradeoff is operational complexity: you need an edge layer or gateway capable of fetching and stitching fragments reliably, with sane fallbacks when a fragment's service is slow or down. Each team publishes their micro-frontend as a versioned NPM package. A "shell" application installs these packages and builds them into a single bundle.
Json
{
  "dependencies": {
    "@company/search-mfe": "^2.3.0",
    "@company/cart-mfe": "^1.8.1",
    "@company/checkout-mfe": "^4.1.0"
  }
}
This is simple to reason about and gives you a single, optimized bundle — but it defeats much of the point of micro-frontends. If Team A ships a fix, the shell still has to bump the dependency, rebuild, and redeploy. You've traded a monorepo for a monolith held together by package.json. This is the modern standard, popularized by Webpack 5 (and now supported by Vite via plugins, and natively in some meta-frameworks). The "shell" application dynamically loads the micro-frontend JavaScript code at runtime, directly in the browser. Teams can deploy updates instantly without rebuilding the shell. A minimal Module Federation setup looks like this on the micro-frontend ("remote") side:
Js
// webpack.config.js — search-mfe (remote)
const { ModuleFederationPlugin } = require("webpack").container;

module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: "searchMfe",
      filename: "remoteEntry.js",
      exposes: {
        "./SearchWidget": "./src/SearchWidget",
      },
      shared: { react: { singleton: true }, "react-dom": { singleton: true } },
    }),
  ],
};
And on the shell ("host") side:
Js
// webpack.config.js — shell (host)
new ModuleFederationPlugin({
  name: "shell",
  remotes: {
    searchMfe: "searchMfe@https://search.example.com/remoteEntry.js",
  },
  shared: { react: { singleton: true }, "react-dom": { singleton: true } },
});
Jsx
// Consuming the remote in the shell
const SearchWidget = React.lazy(() => import("searchMfe/SearchWidget"));

function App() {
  return (
    <Suspense fallback={<Spinner />}>
      <SearchWidget />
    </Suspense>
  );
}
The shared block is the important part — it tells Webpack that both the host and remote should use a single, shared instance of React rather than each shipping their own copy.
If you're moving from a monolith to micro-frontends, resist the urge to rewrite everything at once. A phased rollout is far less risky. Look for parts of the application that already have low coupling to the rest — a settings page, a help center, an admin panel. These make good first candidates because a failure in isolation won't take down the whole app. The shell is the application that owns:
  • Top-level routing
  • Authentication and session context
  • The design system / shared component library
  • Error boundaries around each micro-frontend
Keep the shell intentionally "dumb." Its job is composition, not business logic. Pick your lowest-risk candidate and extract it into its own repository with its own build and deploy pipeline. Wire it into the shell using Module Federation (or your chosen integration strategy) before touching anything else. Before extracting a second micro-frontend, write down:
  • How micro-frontends communicate (see below)
  • Which design tokens and components are shared vs. owned locally
  • What the deployment and versioning process looks like
  • What "done" looks like for a team's ownership boundary
Skipping this step is the single most common reason micro-frontend migrations turn into a mess — teams start improvising communication patterns independently, and you end up with five different ways of doing the same thing. Extract micro-frontends one at a time behind feature flags, verify in production, and only then move on to the next slice. The old monolith can keep serving unmigrated routes throughout.
Decide upfront whether routing is owned centrally (the shell decides which micro-frontend to render) or federated (each micro-frontend owns its own sub-routes). Centralized routing is simpler to reason about; federated routing scales better with many teams but needs a shared routing contract so the shell doesn't need to know every route in advance. How does the "Search" micro-frontend tell the "Cart" micro-frontend that an item was added? A few patterns, roughly in order of coupling (least to most):
  • Custom browser events: window.dispatchEvent(new CustomEvent("cart:item-added", { detail: item })). Loosely coupled, framework-agnostic, but harder to type and debug.
  • URL parameters / query state: Good for state that should be shareable or bookmarkable, bad for anything high-frequency.
  • A shared, framework-agnostic store: A small pub-sub or observable store (not Redux tied to one micro-frontend) that any micro-frontend can subscribe to. This needs to be treated as a public API with its own versioning discipline.
  • Backend-for-Frontend (BFF) as source of truth: For state that really belongs on the server (cart contents, user session), let the server be the single source of truth and have each micro-frontend fetch independently.
Avoid reaching for a single shared global state library across teams — it recreates the tight coupling you were trying to escape, just in JavaScript memory instead of a shared repo. If Team A uses React 18 and Team B uses React 17, the user downloads React twice. Module Federation's shared configuration can deduplicate common libraries at runtime, but it requires discipline:
  • Pin shared dependency versions across teams, or use semver ranges deliberately with singleton: true and strictVersion where it matters.
  • Audit bundle output regularly — it's easy for a shared dependency to silently duplicate after a version bump goes unnoticed.
  • Treat "which libraries are shared" as a governance decision, not an accident of who set up Module Federation first.
Without a strong, centralized design system, the application will look disjointed — different button radii, different spacing scales, different type ramps across micro-frontends built by different teams. The fix is a shared component library (built and versioned like any other package) plus design tokens as CSS custom properties, so visual updates can roll out without every team recompiling:
Css
:root {
  --color-primary: #1a56db;
  --spacing-unit: 8px;
  --radius-md: 6px;
}
Micro-frontends built independently can leak styles into each other. Common mitigations:
  • Shadow DOM for hard isolation (heaviest, but bulletproof).
  • CSS Modules or scoped CSS-in-JS so class names are automatically namespaced.
  • A strict naming convention (e.g., BEM with a team prefix) if you can't use the above.
Each micro-frontend should be wrapped in its own error boundary in the shell, so that a crash in the checkout widget doesn't take down product search:
Jsx
<ErrorBoundary fallback={<CheckoutUnavailable />}>
  <Suspense fallback={<Spinner />}>
    <CheckoutWidget />
  </Suspense>
</ErrorBoundary>
Testing needs a layer for each level of integration:
  • Unit and component tests live inside each micro-frontend's own repo, owned entirely by that team.
  • Contract tests verify that a micro-frontend's exposed interface (props, events, exposed modules) still matches what the shell expects — these catch breaking changes before they reach production.
  • End-to-end tests run against a fully composed environment (staging) and cover cross-micro-frontend flows, like adding an item in Search and seeing it reflected in Cart.
  • Visual regression tests catch design-system drift across teams.
Independent deployability is the entire point, so each micro-frontend needs:
  • Its own CI/CD pipeline, triggered by its own repo.
  • A versioned remoteEntry.js (or equivalent), often published to a CDN with cache-busting per deploy.
  • A rollback strategy that doesn't depend on the shell — if a micro-frontend ships a bad build, you should be able to point the shell back at a previous version without redeploying the shell itself.
  • Feature flags at the shell level, so a broken micro-frontend can be disabled without a full rollback.
Once ownership is split across teams, so is on-call responsibility. Each micro-frontend should ship its own:
  • Error tracking (tagged by micro-frontend name/version, so a crash is attributable to the right team).
  • Performance monitoring (bundle size over time, load time per fragment).
  • A health-check or "unavailable" fallback state the shell can detect and gracefully degrade around.

Micro-frontends are not a silver bullet and introduce significant complexity:
  • Performance Overhead: Duplicated dependencies (React, date libraries, UI kits) are the most common way micro-frontend migrations quietly regress page-load performance. Proper configuration — shared dependencies in Module Federation, aggressive bundle auditing — is critical, not optional.
  • Design Inconsistencies: Without a strong, centralized Design System (CSS variables, shared component library), the application will look disjointed, and users will notice even if they can't articulate why.
  • State Sharing: Passing state via URL parameters, custom browser events, or a shared global store requires careful architectural planning up front — retrofitting it after five teams have improvised their own approach is painful.
  • Operational Complexity: More repos, more pipelines, more moving parts to monitor. This is a real cost that only pays off once you have enough teams to justify it.
  • Debugging Across Boundaries: A bug that spans two micro-frontends (say, a stale event listener between Search and Cart) is harder to trace than a bug in a single codebase, because the two teams may not even use the same tooling.
  • Governance Overhead: Someone needs to own the shared contracts — the event schema, the design tokens, the shared dependency versions — or they drift and rot.

Micro-frontends are an organizational scaling tool, not a performance hack. The decision should be driven by team topology, not technical taste. Good fit:
  • 30+ frontend engineers across multiple teams and time zones
  • Teams that need to deploy independently, on their own schedules
  • A product surface that's naturally divisible along team/domain lines (search, cart, checkout, admin)
Poor fit:
  • A small team of 3–10 developers — the coordination overhead of micro-frontends will slow you down, not speed you up
  • A single cohesive feature that doesn't have a natural seam
  • A team without the platform/DevOps maturity to support multiple independent pipelines
If you have a small team, stick to a well-organized monolith with clear module boundaries — you can always extract micro-frontends later once the organizational pain actually shows up.
Micro-frontends solve an organizational problem — too many people, in too many timezones, stepping on each other in one repository — using an architectural pattern. They are not free: you're trading one set of problems (merge conflicts, coordinated releases) for another (duplicated dependencies, cross-team contracts, operational overhead). The teams that succeed with micro-frontends tend to share three things: a strong shell/platform team that owns the shared contracts, a real design system rather than an aspirational one, and the discipline to define communication patterns before scaling past two or three micro-frontends. Get those right, and the architecture genuinely does let large frontend orgs move faster, independently, without the whole application coming down because of one team's bug.
On this page
Hey! Got questions?Ask me anything 👋