Advanced Frontend
When Micro-Frontends Stop Sharing a Design System
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 mismatchrequiredVersion: '^3.0.0'`— throws at runtime if the loaded version doesn't satisfy the semver rangestrictVersion: 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.0range 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: truefor 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
// 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
// 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.
Showing: Aligned — singleton
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-changedevent fires, it misses the current token. Solve with amfe-auth-state-requestevent 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.
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