Skip to story

Advanced Frontend

When Micro-Frontends Stop Sharing a Design System

11 min read · May 31, 2026 ★ Flagship

Reading level

Two teams, two versions, one broken brand

Team A owns the product catalog micro-frontend. Team B owns checkout. Both use the company's design system. Six months ago, Team A upgraded to design system v3.2. Team B stayed on v2.8 because they had a freeze. A user navigates from catalog to checkout: the button border radius jumps from 8px to 4px, the blue shifts slightly, and the spacing feels different. No errors, just a silently broken experience.

This is design system version drift — and it's one of the most common failure modes when teams own independent micro-frontends.

In a monolith, the design system version is a single dependency in a single package.json. All components share the same instance. In a micro-frontend architecture, each team's bundle can include its own copy of the design system at their own version. Without explicit enforcement, drift is the natural outcome — teams update on their own schedule, pin during freezes, and fall behind.

The problem has two layers: (1) runtime duplication — the same design system code ships twice in the browser, once per micro-frontend, increasing bundle size; (2) visual inconsistency — different versions have different token values, component APIs, and visual outputs. Layer 1 is a performance issue; Layer 2 is a UX/brand issue. Both are solved by the same mechanism: singleton shared dependencies in Module Federation.

Module Federation singleton shared dependencies

Module Federation lets micro-frontends share dependencies at runtime — the design system loads once and is reused by all micro-frontends. The key setting is singleton: true: if multiple micro-frontends try to load the design system, only the first-loaded version runs.

// Shell app's webpack.config.js (module federation plugin)
new ModuleFederationPlugin({
  name: 'shell',
  shared: {
    '@company/design-system': {
      singleton: true,       // only one instance
      requiredVersion: '^3.0.0',
      eager: true,           // shell loads it first
    },
  },
});

With this config, all micro-frontends receive the shell's design system version. No team can load a different version at runtime.

The version enforcement options:

  • singleton: true — one instance; first loaded wins; logs a warning if version mismatch
  • requiredVersion: '^3.0.0'` — throws at runtime if the loaded version doesn't satisfy the semver range
  • strictVersion: true — throws if exact version doesn't match (use for breaking changes)
// Each micro-frontend also declares the shared dep
// They don't need to specify a version — they accept the shell's
new ModuleFederationPlugin({
  name: 'checkout',
  shared: {
    '@company/design-system': {
      singleton: true,
      // no requiredVersion here: accept whatever shell provides
    },
  },
});

The governance model that makes this sustainable:

  • Shell owns the design system version — the shell app's package.json is the single source of truth for the shared design system version
  • Minor versions auto-update^3.0.0 range means all micro-frontends automatically receive 3.x patches when shell upgrades
  • Major versions are migration events — breaking token/API changes require coordinated migration across all micro-frontends before the shell upgrades
  • CI enforcement — a CI check that validates all micro-frontends declare the same singleton: true for the design system prevents drift from being accidentally introduced

The deeper principle: governance through tooling beats governance through process. A Jira ticket to "keep design system in sync" will fail. A webpack config that makes mismatched versions a runtime error will not.

The session token that outlived the session

A fintech platform had four micro-frontends sharing a user session: identity, payments, portfolio, and notifications. Early on, the session MFE was the authority — every MFE called its API to get the current auth token before making requests. Then a developer on the payments team added a module-scoped cache to avoid the round-trip latency on every route change.

For two months, nothing went wrong. Then a user's session expired server-side at 11:58 PM. The session MFE cleared its state and broadcast an auth-changed event. The payments MFE received the event — but its module-scoped cache still held the old token and the subscription handler never updated it. Payment confirmations went through on an expired session for the next 22 minutes until a page reload flushed the cache.

The incident was invisible to monitoring. No 401 errors — the backend token validation had a 30-minute grace window for "recently expired" tokens as a UX concession. The payments MFE passed auth checks with the stale cached value. Users received payment confirmations that were technically processed in a session the system had already invalidated.

The event-bus contract that made auth state unforkable

The post-incident fix was a strict boundary contract: no MFE is permitted to hold its own copy of auth state. The session MFE owns the auth token and broadcasts it exclusively via a typed event bus message (mfe-auth-changed). All other MFEs subscribe — they receive the current token in the event payload and must discard it when the next event arrives, whether that event carries a new token or a null (logged-out) value.

The change removed every local auth cache from every MFE. Where round-trip latency to the session API had been the concern, the event bus subscription was in fact faster — the session MFE pushed updates proactively rather than waiting for polling. The perceived performance improvement was an unexpected benefit of fixing the correctness problem.

The architectural lesson: MFE boundaries must be enforced at the data layer, not just the UI layer. Shared mutable state that can be forked — whether a module variable, a localStorage key, or a cookie read by multiple MFEs — is a ticking correctness hazard. The session MFE is the single writer; all others are read-only subscribers. Any deviation from this ownership model is a bug waiting to manifest.

Pattern at a glance

Before — module-scoped token cache (stale state bug)
// payments-mfe/src/auth.js
// ❌ Module-scoped cache — survives session expiry
let cachedToken = null;

export async function getToken() {
  if (cachedToken) return cachedToken; // returns stale token!
  cachedToken = await sessionApi.getToken();
  return cachedToken;
}

// No invalidation on mfe-auth-changed event
// Token outlives the session until page reload
After — event bus subscription, no local copy
// payments-mfe/src/auth.js
// ✅ Subscribe to session MFE; never hold own copy
let currentToken = null;

window.addEventListener('mfe-auth-changed', (e) => {
  currentToken = e.detail.token; // null on logout
});

export function getToken() {
  return currentToken; // always reflects session MFE state
}

// session-mfe/src/broadcaster.js
function broadcastAuthChange(token) {
  window.dispatchEvent(new CustomEvent('mfe-auth-changed', {
    detail: { token } // null when session expires
  }));
}

Try it: drifted vs aligned design system

"Drifted" shows two micro-frontends side-by-side with different design system versions — notice the button border radius, spacing, and color token differences. "Aligned" shows the same two micro-frontends sharing a singleton — visually consistent across the boundary.

Inspect the buttons in each mode. In drifted mode, the catalog button uses the v3 token scale (border-radius: 8px, gap: 12px) and the checkout button uses the v2 token scale (border-radius: 4px, gap: 8px). Same brand, two versions, silent divergence.

Open DevTools → Sources in the drifted mode. You'll find both design system versions in the bundle — the same library shipped twice. In aligned mode, one version, shared. This is the bundle size argument alongside the visual consistency argument.

⚡ Interactive demo

Implementation depth

Module Federation's shared config is where boundary contracts are encoded. The key options are singleton: true (one instance; first-loaded version wins), requiredVersion (semver range the loaded version must satisfy), and strictVersion: true (throws if exact version doesn't match). The session MFE should be declared eager: true so it initialises before any consumer MFE attempts to read auth state.

// shell webpack.config.js — session MFE as eager singleton
new ModuleFederationPlugin({
  shared: {
    '@company/session-mfe': {
      singleton: true,
      eager: true,        // session MFE initialises first
      requiredVersion: '^2.0.0',
    },
  },
});

Common pitfalls when enforcing MFE auth boundaries:

  • localStorage as shared state — any MFE can read and write localStorage, making it another forkable cache. Treat localStorage as MFE-private; never use it to share auth state across MFEs.
  • Event bus message ordering — if a MFE loads after the initial mfe-auth-changed event fires, it misses the current token. Solve with a mfe-auth-state-request event that the session MFE responds to on demand.
  • Singleton version mismatch warning — when webpack logs "Unsatisfied version X of shared singleton module Y" it means a MFE has a different version expectation. This is a contract violation; treat it as a build error in CI, not a warning to ignore.
  • Direct API calls bypassing the session MFE — adding a CI lint rule that flags direct imports of auth APIs from non-session MFE packages prevents the pattern from re-emerging.

References

Remember

Key takeaways

  • In micro-frontend architectures, each team's bundle can ship its own copy of the design system. Without enforcement, versions drift and the UI becomes visually inconsistent.
    Module Federation's singleton: true makes a shared dependency load only once at runtime — the shell's version wins. All micro-frontends receive the same instance.
    Shell owns the design system version. Minor versions auto-update via semver range. Major versions are migration events. CI enforcement validates all micro-frontends declare the same singleton config.
  • The singleton config goes in every micro-frontend's Module Federation plugin, not just the shell. All participants must agree to share the single instance.
    requiredVersion: '^3.0.0' throws a runtime error for mismatched versions — turning silent visual drift into a loud failure that gets fixed. strictVersion: true enforces exact version matches for breaking-change releases.
    Governance through tooling beats governance through process. A webpack config that makes version mismatch a runtime error is more reliable than a Jira ticket asking teams to stay in sync.

Enjoyed this case?

Case 1 of 1 in Advanced Frontend · 3 of 31 live

Keep going

Finish this takeaway, then continue the track — Casey saved your spot locally.

Sign in with email to sync progress across devices (beta).

Inside the Casebook

New cases every few weeks — patterns from production UI engineering. Double opt-in, easy unsubscribe.

No spam. Unsubscribe anytime. Emails sent via Buttondown.

RSS feed
Casey, junior (idle)
Casey · Junior

Hey! I'm Casey — scroll through the case and I'll chime in with hints.