Skip to content
← Back to Blog

Micro-frontends at Scale: Module Federation + Shadow DOM Isolation (What Actually Worked)

· 12 min read
architecturemicro-frontendsmodule-federationplatformfrontend

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, querySelector scope, 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 .btn styles
  • libraries that appendChild(document.body, …) or inject <style> into document.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:

  1. Composition boundary (Module Federation): how code is loaded and versioned at runtime.
  2. UI boundary (Shadow DOM): how DOM + CSS are contained so one team cannot accidentally break another team’s UI.
  3. 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: ./indexmount().
  • 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 — A shell composing multiple independently-deployed UI remotes

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:

  1. Router matches a path (e.g. /feature/* -> a remote).
  2. Host loads the remote entry (e.g. loadRemote("@org/feature")).
  3. Host renders a mount point (e.g. <micro-app remote="@org/feature"></micro-app>).
  4. <micro-app> attaches Shadow DOM and calls the remote’s mount(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 a shadowRoot so the remote can render and inject styles inside the boundary.
  • Why props: we pass a portalContainer so portal-based UI (modals, toasts, dialogs) can target a node inside the shadow tree instead of document.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 — Desired: a shell↔remote sequence for mount/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.head is 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 like dontAppendStylesToHead: 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; adoptedStyleSheets can 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 — CSS path: remote styles end up inside the shadow root (not document.head)

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: true or 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.