Micro-frontends at Scale: Module Federation + Shadow DOM Isolation (What Actually Worked)
Micro-frontends are mainly an organizational tool: independent teams can ship UI without turning every release into a cross-team negotiation. If you’re new to the idea, start with Micro Frontends.
Once multiple independently-built apps share the same page, isolation becomes the engineering problem you spend time on:
- CSS leaking across boundaries
- DOM assumptions colliding (IDs,
querySelectorscope, portals, focus traps) - shared library drift (React, router, design system)
- runtime failure modes (a remote is slow, down, or mis-versioned)
This post documents the model that held up in production for us: runtime composition via Module Federation + UI isolation via Shadow DOM.
It is not a sandbox (JavaScript still shares the same window). The goal is a predictable blast radius boundary, backed by enforceable platform contracts.
If these primitives aren’t fresh, these references will make the rest of the post easier to follow:
The isolation problem is not “code splitting”
If your micro-frontend architecture only answers “how do we load code?”, you still end up fighting runtime coupling:
- global CSS resets
- conflicting
.btnstyles - libraries that
appendChild(document.body, …)or inject<style>intodocument.head - React portals that escape their container
- “tiny” shared utilities that fork and diverge into 6 versions
We made isolation explicit and multi-layered, because these problems show up in different layers:
- Composition boundary (Module Federation): how code is loaded and versioned at runtime.
- UI boundary (Shadow DOM): how DOM + CSS are contained so one team cannot accidentally break another team’s UI.
- Foundation boundary (shared foundation): the shared primitives remotes build on (providers, UI, fetch, ACL, logging, feature flags) so teams don’t reach into host internals or reinvent cross-cutting behavior.
The rest of this post follows that layering: pick a UI boundary, define a narrow mount contract, make style delivery explicit, then harden shared dependencies and production failure modes.
Why Shadow DOM (and not iframes)?
We tried (or seriously evaluated) all three:
1) Convention-based CSS isolation (BEM/CSS Modules)
Conventions work until the system grows and “global” CSS starts arriving from multiple directions:
- third-party CSS enters the repo
- one team lands a global reset in the design system
- someone uses a Tailwind preflight tweak
- a seemingly harmless selector becomes too broad (
button { … })
Conventions reduce the probability of collisions; they do not provide an enforcement boundary.
2) Iframes
Iframes give you the cleanest isolation boundary, but the integration overhead is substantial:
- cross-app routing/navigation becomes awkward
- auth/session propagation becomes more complex
- consistent theming is harder to enforce
- a11y and focus management become tricky
- performance can degrade (multiple browsing contexts)
For internal tools, iframes can be a pragmatic integration strategy — we used them to wrap a few legacy projects during migration. For a cohesive product UI, we wanted something lighter-weight.
3) Shadow DOM
Shadow DOM gave us the boundary we needed for CSS + DOM (selector scoping and DOM encapsulation), while still allowing:
- in-page routing under a single shell
- shared runtime libraries (React, observability)
- a unified header/nav
Two details to internalize up front:
- Shadow DOM does not isolate JavaScript: remotes still share the same
window. - Some things still cross boundaries: inherited styles and CSS custom properties can flow through; events can be retargeted and may require explicit handling (see the failure modes section).
That is why the “platform guardrails” section matters: isolation is partly browser primitives, partly disciplined runtime contracts.
The high-level architecture
- A host app (the “shell”) owns routing, auth/session, global navigation, and the “front door” UX.
- A shared foundation (federated remote) provides UI primitives, providers, and cross-cutting utilities (fetch, ACL, logging, feature flags).
- Each domain micro-frontend is a remote that exposes a single entrypoint:
./index→mount(). - The host loads remotes via Module Federation (Webpack Module Federation or equivalent implementations) and mounts them into a dedicated shadow root per route.
- The mount point is a custom element (e.g.
<micro-app>), so mounting/unmounting follows the DOM lifecycle.

Figure 1: The shell loads a domain remote at runtime and mounts it into a per-route ShadowRoot. A foundation remote supplies shared UI/providers and shared singletons.
Conceptually, the request lifecycle is:
- Router matches a path (e.g.
/feature/*-> a remote). - Host loads the remote entry (e.g.
loadRemote("@org/feature")). - Host renders a mount point (e.g.
<micro-app remote="@org/feature"></micro-app>). <micro-app>attaches Shadow DOM and calls the remote’smount(shadowRoot).
With that architecture, the highest-leverage decision is the host/remote interface. Keep it narrow, and you can change internals without coordinating every team.
A practical mount contract
To keep the host/remote interface stable over time, every remote exposes exactly one thing to the host: a mount() function.
- Signature:
mount(el: Element | ShadowRoot, props?) -> unmount() - Why
ShadowRoot: the host passes ashadowRootso the remote can render and inject styles inside the boundary. - Why
props: we pass aportalContainerso portal-based UI (modals, toasts, dialogs) can target a node inside the shadow tree instead ofdocument.body(relevant for React portals).
Host-side mounting:
<micro-app remote="@org/feature"></micro-app>
Sketch of what that element does:
class MicroAppElement extends HTMLElement {
#unmount: undefined | (() => void);
async connectedCallback() {
const container: Element | ShadowRoot = this.attachShadow({ mode: "open" });
const remote = await loadRemote(this.getAttribute("remote")!);
this.#unmount = await remote.mount(container, { portalContainer: container });
}
disconnectedCallback() {
this.#unmount?.();
}
}
Remote-side mounting:
export async function mount(container: Element | ShadowRoot, props?: { portalContainer?: Element | ShadowRoot }) {
injectStylesInto(container);
const root = createRoot(container);
root.render(<App portalContainer={props?.portalContainer ?? container} />);
return () => root.unmount();
}

Figure 2: The element connects, attaches a shadow root, loads the remote, and calls mount(). The remote injects styles, renders, and returns unmount(). On disconnect, the shell calls unmount() so the remote can clean up.
At this point, the contract defines rendering location (container), portal target (portalContainer), and cleanup (unmount). The remaining requirement is style delivery — with Shadow DOM, CSS can’t be treated as an implicit side-effect.
The missing piece: styles
Shadow DOM prevents document-level selectors from matching inside the boundary. That is the isolation mechanism, and it also means you do not get your styles “for free”.
To keep the terminology concrete:
- Standalone mode: a remote runs by itself (common for local development). In this mode, appending CSS to
document.headis usually fine. - Federated mode: the shell loads the remote at runtime via Module Federation and mounts it into a shadow root. In this mode, you must route styles into the shadow root.
With that definition, the contract is straightforward (the exact implementation depends on your bundler and federation runtime):
- In federated mode, do not append CSS to
document.head. Head injection leaks across remotes, and it still won’t style content inside a shadow root. Some implementations expose a flag likedontAppendStylesToHead: true; other stacks require capturing CSS output and redirecting it. - At mount time, inject the remote’s CSS into the shadow root.
<style>tags are the simplest reliable default;adoptedStyleSheetscan be a nice optimization when you control browser support. - Cache/dedupe so remounts don’t refetch the same styles.
Minimal sketch:
async function injectCssInto(shadowRoot: ShadowRoot, hrefs: string[]) {
const cssTexts = await Promise.all(hrefs.map((href) => fetch(href).then((r) => r.text())));
const styleTags = cssTexts.map((css) => {
const style = document.createElement("style");
style.textContent = css;
return style;
});
shadowRoot.prepend(...styleTags);
}
How you get hrefs is stack-specific (manifest, runtime hook, or remote metadata). The key is where the CSS ends up: inside the shadow root, not the document.

Figure 3: In federated mode, keep CSS out of document.head and inject per-remote styles into the shadow root during mount().
Shared libraries and versioning (what we enforced)
Once mounting and styling are deterministic, the next class of incidents tends to be dependency drift.
Module Federation makes it easy to accidentally load multiple copies of the same dependency graph at runtime. It also makes it possible to enforce the opposite: shared singletons.
We categorized dependencies into three buckets:
Bucket A: must be singletons
- React + ReactDOM
- the router (if the shell owns routing)
- observability/logging SDKs (so traces aren’t fragmented)
- design tokens package (as data)
We marked these as shared singletons with strict versioning, and we treated upgrades like platform changes (coordinated, with a migration window).
Bucket B: can be duplicated (intentionally)
- domain-specific libraries unique to a remote
- experimental UI libraries during a migration
Duplicating has a cost (bundle weight, memory), but it’s sometimes worth it to keep teams unblocked.
Bucket C: should never be used directly
This is the “platform API” category:
- auth token access
- permissions
- navigation
- environment/tenant config
If every remote implements these differently (or reaches into host-only internals), you’ve recreated the monolith coupling through the side door. We funnel this through the shared foundation and keep the host surface area intentionally small.
At this point, the happy path is mostly solved. The remaining work is making the system resilient under partial failures and mismatched assumptions.
The failure modes that mattered in production
1) Remote failures should not take down the shell
The shell must treat remotes like flaky networks:
- timeouts
- retries with backoff
- a per-remote disable (“kill switch”)
- a decent fallback UI
If you can, cache “last known good” remote versions so a bad deployment can be rolled back quickly without redeploying the whole shell.
2) Portals escape their sandbox
Many modal/toast libraries portal to document.body.
Inside Shadow DOM, that means:
- the UI escapes the micro-app boundary
- styles might not apply
- stacking/layering conflicts become harder to debug
Our guardrail: every remote must provide a portal root inside its shadow tree and wire UI libraries to target it.
3) Event propagation changes at the boundary
Shadow DOM changes event propagation in ways you can feel in:
- analytics click tracking
- global keyboard shortcuts
- drag/drop
Some events are composed and cross the boundary; others stop at the shadow root. We solved this by defining a small set of platform events and explicitly dispatching them as CustomEvents where needed.
4) Leaks from missing cleanup
Micro-frontends mount/unmount more often than expected (route changes, feature flags, hot reload, experiments).
We put a lot of weight on the unmount() contract and required remotes to:
- abort fetches with
AbortController - unsubscribe from stores/event buses
- disconnect observers
If a remote can’t unmount cleanly, it will eventually destabilize the shell.
Rollout strategy: Strangler Fig, not big bang
What worked for us:
- pick a low-risk surface area first (one route, one team, predictable traffic)
- build the platform primitives (shell contract, loader, error boundaries, logging)
- ship behind a feature flag
- add a kill switch (remote-level disable) before you need it
- expand one domain at a time, using each migration to harden guardrails
This is the Strangler Fig pattern applied to UI composition: iterate on the contract while keeping risk localized.
Platform guardrails (the operational work that made everything else possible)
Micro-frontends stay operable only if teams share a small set of contracts, templates, and diagnostics.
Our most effective guardrails weren’t strict rules; they were:
- A template repo for remotes (build config, linting, observability wired, portal root pattern, base styles)
- A CLI to run a remote against production versions of everything else locally
- Dependency policy checks (prevent importing platform internals; prevent duplicate React)
- Production diagnostics (remote version, load time, error rate, health checks)
- Performance budgets per remote (bundle size + runtime metrics)
The mental model we pushed: platform is paved road, not gatekeeping. You can go off-road, but the trade-offs should be explicit and owned.
What I’d do differently next time
- Start with the contract layer before arguing about bundlers.
- Treat Shadow DOM + portals + focus management as first-class requirements.
- Invest earlier in observability for remote loading and mount performance.
- Decide your shared dependency philosophy upfront (strict singletons vs flexibility) and be honest about the coordination cost.
A checklist you can reuse
- Host owns top-level routing and navigation.
- Remote contract:
mount(el: Element | ShadowRoot, props?) -> unmount(). - Shadow DOM boundary per mounted remote (
attachShadow({ mode: "open" })). - Federation runtime does not append CSS to
document.head(e.g.dontAppendStylesToHead: trueor equivalent). - Remote injects both foundation styles and its own styles into the shadow root (e.g.
getStyles(globalThis["css__..."])). - Portal-based UI (modals/toasts/dialogs) targets a portal container inside the boundary (e.g.
portalContainer={el}/ provider-level portal config). - Shared singleton policy enforced (React, router, Redux).
- Remote failure is isolated (fallback UI, timeout, kill switch, rollback).
If you only take one thing from this post: micro-frontends are not an architecture diagram; they are an operating model. Module Federation makes composition feasible, Shadow DOM makes UI boundaries enforceable, and the real win comes from treating the shell as a platform with contracts, versioning, and guardrails.