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, since 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