cf.completefrontend
My progress
SYSTEM DESIGN · #126

Design a micro-frontend shell

An application shell that composes frontends built and deployed by several teams into one product: it routes URLs to apps, loads each app from a runtime manifest, mounts and unmounts it, and owns the header, navigation, and login. The hard parts are deciding whether to split at all, picking a composition method, sharing one copy of React without version fights, keeping one broken app from taking down the page, and shipping each team's code on its own schedule without making the product slow or inconsistent.

The question

Our company has grown to 12 frontend teams working on one web product: search, product pages, checkout, account, a seller dashboard, and more. Today they all ship one big single-page app from one repository, and releases are slow: one team's broken test blocks everybody. Leadership wants each team to build and deploy its part on its own.

Design the shell that makes this work: the piece that loads in the browser first, decides which team's app to show for a URL, loads that app's code, and keeps the header, navigation, and login consistent. Walk me through how apps are composed, how they share dependencies and talk to each other, what happens when one of them fails, and how deploys and rollbacks work.

Real-life examples: IKEA, Spotify's older desktop app, DAZN, Zalando Project Mosaic, FINN.no (Podium), Module Federation setups

Requirements: scoping a shell for many teams

This question sounds like a build-tool question, but it is an organization question with a frontend answer. The shell exists because many teams need to ship without waiting for each other, and every decision in it trades some of that independence against speed, consistency, or safety. So the first questions are about people and releases, not about webpack.

A micro-frontend is a part of the product (usually a whole area, like checkout) that one team builds, tests, and deploys on its own, and that runs inside a bigger page. The shell (also called the host, container, or app frame) is the small program that loads first, draws the common frame, and plugs the right micro-frontends into it. A remote is a micro-frontend that the shell loads at runtime from somewhere else, usually a CDN.

Ask how many teams there are and how often they deploy, because that tells you whether the split pays for itself at all. Ask whether pages must be found by search engines, because client-side composition renders nothing until JavaScript runs. Ask whether all apps use the same framework, because sharing one copy of React is the single biggest performance lever. Ask how isolated apps must be from each other: a shared employee portal and a page that runs code from outside vendors need very different walls.

QuestionAnswer we assumeWhat it changes
How many teams, and how often do they ship?12 teams, 8 of them with their own area. Each wants to deploy several times a day.Deploy independence is the main goal, so runtime composition with a manifest beats build-time packages.
Is it one product to the user?Yes. One header, one login, one look. Users must not notice team boundaries.A shared layout owned by the shell, a shared design system, and shared auth.
Do all teams use the same framework?React for 11 apps. One legacy area is Angular and will stay for two more years.Share one React copy; the contract between shell and apps must not assume React.
Do public pages need SEO?Product and search pages, yes. Checkout, account, and seller tools, no.Server or edge composition for public pages; client-side composition for the logged-in app.
Who writes the code that runs in the shell?Only our own teams. No outside vendors.Soft isolation (naming, CSS scoping, error boundaries) is enough; no iframes by default.
What devices and networks?Desktop and mobile web, with mid-range Android phones on 4G as the budget device.Every extra round trip and every duplicate library shows up in Largest Contentful Paint.
Can apps appear side by side on one page?Yes on the dashboard: up to 4 widgets from different teams.Several mounts at once, each with its own error boundary and timeout.

Functional requirements

  • Map URL prefixes to apps: /search to the search team's app, /checkout to checkout, and so on, and switch apps on navigation without a full page reload.
  • Read a manifest at startup that says which version of each app is live and where its code is.
  • Load an app's code on first use, mount it into a slot in the page, and unmount it cleanly when the user leaves its area.
  • Draw the shared frame: header, navigation, account menu, footer, and global toasts.
  • Own login and the session, and give every app a way to know who the user is and to make authenticated calls.
  • Let apps tell each other about changes, like the cart count after an item is added on a product page.
  • Show a useful fallback when an app fails to load or crashes, while the rest of the page keeps working.

Here is what the user sees. Everything outside the content slot belongs to the shell. The content slot belongs to whichever app owns the current URL, and on the dashboard it splits into widget slots owned by several teams.

Wireframe of the product at acme.com/checkout/payment. The top bar belongs to the shell: the Acme logo, navigation with Search, Orders, and Sell, a cart badge showing 3, and the account menu. Below it is the content slot, owned by the app for the current URL, here the checkout app, mounted by the shell. A note lists the slot's states: skeleton while loading, mounted, or a fallback on timeout or error. At the bottom, the footer and toast area also belong to the shell.acme.com/checkout/paymentAcmeSearch Orders Sellcart 3accountcontent slot: owned by the app at /checkoutcheckout appmounted by the shellslot states:skeleton, mounted,or fallbackfooter and toasts (shell)
Everything outside the dashed slot is the shell's and stays mounted when the user moves between areas. The slot is the only place an app may render, and it is always in one of three states: skeleton, mounted, or fallback.

Non-functional requirements

  • Independent deploys: a team ships its app without rebuilding or redeploying the shell or any other app. Rollback in under a minute.
  • Performance: Largest Contentful Paint under 2.5 seconds at the 75th percentile on mobile. The shell itself stays under about 60 KB of compressed JavaScript; each app's first load stays under its team budget.
  • Fault isolation: one app throwing, timing out, or returning a 404 must not blank the header or another app.
  • Consistency: same fonts, colors, spacing, and component behavior everywhere, from one design system.
  • Observability: every error and slow interaction is tagged with the app and version that caused it, so the right team gets paged.

Out of scope

We leave out the backend services behind each app, the design system's own internals (the design-systems lesson covers them), native mobile apps, and running code from outside vendors. That last one would push us toward iframes everywhere; we will still say when iframes are the right tool.

Check your understanding · Exercise 1

Which answers from the interviewer would change the design of the shell the most? Select all that apply.

What makes a micro-frontend shell hard

A normal single-page app is built once, by one build, into one set of files. Everything inside it agrees on the versions of every library because there is exactly one package-lock.json. A shell breaks that assumption on purpose. Five properties follow from it, and every later chapter is about one of them.

  • Code that meets for the first time in the user's browser. The shell and the checkout app were built on different days, by different pipelines, against different lockfiles. Nothing checked that they work together until a real user loaded both. Contracts and tests have to replace the type checker that a single build gave you for free.
  • Many owners, one page. Twelve teams write CSS, register global listeners, and install polyfills into the same document. Without rules, the last stylesheet loaded wins and one team's button { } rule restyles everybody's buttons.
  • Partial failure is normal. With 8 remotes on 3 CDNs, something is always slow or broken somewhere. A monolith either loads or doesn't; a shell must load the parts that work and fence off the parts that don't.
  • Duplication is the default. Each app bundles its own React, its own date library, its own copy of the design system. Left alone, a user who visits four areas downloads React four times. Sharing fixes the bytes but couples the teams again, which is the very thing we split to avoid.
  • Extra round trips. Before an app can render, the browser needs the shell, then the manifest, then the app's entry file, then its chunks, then its data. Each step waits for the one before. On a phone with 150 ms of round-trip time, five sequential steps cost most of a second before any data arrives.

Keep one sentence in mind for the whole interview: a micro-frontend architecture buys deploy independence and pays for it in runtime complexity. Each chapter either protects that independence or pays down part of the bill.

Think first

A single React app with React.lazy already loads each area's code on demand. What does a micro-frontend shell add that lazy loading doesn't?

Should you split at all?

Strong candidates question the premise before designing the shell. Micro-frontends solve an organizational problem: teams blocking each other on one release. If that problem isn't real, the shell is pure cost. The micro-frontends lesson covers the general idea; here we apply it to our 12 teams.

Start from the naive answer: "split everything into micro-frontends, one per team." Picture what it costs a small company. Four teams, one product, one release a week. After the split they own four build configs, four dependency upgrade schedules, four copies of the routing setup, and a shell nobody owns.

A React upgrade that used to be one pull request becomes four, plus a coordination meeting because the shared React must move in lockstep. Pages got slower because two apps still bundle their own copy of a charting library. Nobody ships faster, because nobody was blocked before.

Decision How should 12 teams organize the code of one web product?

One app, one repo (the monolith)

One build, one deploy, one lockfile. Teams own folders.

Everyone merges into the same main branch and ships on one train.

Pros

  • Simplest to run. One version of everything, checked by one type checker.
  • Smallest bundles: shared code is deduplicated by the bundler.

Cons

  • One team's broken test or slow review blocks every release.
  • Build and test times grow with the whole codebase; 20 to 40 minute pipelines are common at this size.
  • Ownership boundaries are only conventions, so they erode.

Modular monorepo

One repo, many packages with enforced boundaries (each area is a package with a public API).

Tooling builds and tests only what changed; still deployed as one app, or as a few.

Pros

  • Atomic changes across packages: one pull request upgrades React everywhere.
  • Strong boundaries without runtime cost; affected-only CI keeps pipelines short.

Cons

  • Release is still shared: one deploy carries every team's changes.
  • Needs build tooling investment (Nx, Turborepo, or similar) and someone to own it.

Micro-frontends with a shell

Each area is built and deployed on its own and loaded at runtime.

A shell routes, loads, and frames them; a manifest decides which versions are live.

Pros

  • True deploy independence: checkout ships at 3 pm without asking anyone.
  • A failing app can be rolled back alone; legacy areas can stay on old frameworks while new ones move on.

Cons

  • Runtime integration: version skew, duplicate code, extra network round trips.
  • Consistency needs active work: a design system, contracts, and shared tests.
  • A platform team must own the shell, the manifest, and the tooling.

What we pick: For 12 teams that each want to ship several times a day, and with one legacy Angular area, micro-frontends earn their cost. We still keep each app's internals in a modular monorepo per team or a shared monorepo with separate deploys (see the monorepos lesson): the two ideas combine well. For four teams on one release train, we would pick the modular monorepo and say why.

Three ways to organize 12 teams, compared on four things. Monolith: teams can't ship alone, CI takes about 30 minutes, a React upgrade is 1 pull request, and there is no runtime integration risk. Modular monorepo: teams still can't ship alone, CI takes about 5 minutes thanks to affected-only builds, a React upgrade is 1 pull request, no runtime risk. Micro-frontends: teams ship alone, CI is about 5 minutes per app, a React upgrade takes 12 pull requests, and runtime risk is high from version skew and extra loads.ships aloneCI timeReact upgraderuntime riskmonolithno30 min1 PRnonemonorepono5 min1 PRnonemicro-FEyes5 min12 PRshighgreen helps the teams, red costs them
The modular monorepo fixes slow pipelines without any runtime cost. Only micro-frontends let a team ship alone, and they pay for it in coordinated upgrades and runtime risk.

Split along areas, not along layers

Where you cut matters as much as whether you cut. Cut vertically: one team owns a whole area of the product (checkout: its pages, its components, its data calls) and its URL prefix. Don't cut horizontally into a "header app", a "buttons app", and a "forms app": every feature would then need changes in three apps from three teams, which is a slower version of the monolith.

A useful test: for the last 20 features shipped, how many areas did each one touch? If most touched one, vertical micro-frontends fit. If most touched four, the boundaries are wrong, and splitting the deploy will make every feature a cross-team release.

Check your understanding · Exercise 2

Which situations are good reasons to adopt micro-frontends with a shell? Select all that apply.

Composition: where the pieces meet

Composition is the moment separate apps become one page. It can happen at three times: when the code is built, when the server or CDN answers the request, or in the browser after the page loads. The later it happens, the more independent the teams are, and the more work the browser does at runtime.

Three places where apps can be composed, from left to right in time. At build time: npm packages, where the shell rebuilds to update, and the design system as a shared library. On the server or edge: nginx SSI with include virtual, CDN ESI with esi:include, and an edge worker that streams fragments with timeouts. In the browser: Module Federation with a share scope, import maps written from the manifest, and iframes with full isolation. An arrow along the bottom says that later composition means more independent teams and more work in the browser.build timeserver or edgebrowsernpm packagesshell rebuildsdesign systemshared librarynginx SSIinclude virtualCDN ESIesi:includeedge workerstream, timeoutsModule Federationshare scopeimport mapsfrom the manifestiframesfull isolationlater: more independent teams, more work in the browser
Moving right buys independence: a team can ship without the shell rebuilding. It also moves work into the user's browser, where version checks, loading, and failures now happen.

The naive option is build-time composition: each team publishes its app as an npm package, like @acme/checkout@4.2.0, and the shell installs them all and builds one bundle. It feels clean.

Then checkout fixes a payment bug on a Friday and publishes 4.2.1. Nothing changes for users until the shell team bumps the version, rebuilds everything, and redeploys. The shell team becomes the release train we were trying to remove. Build-time packages are great for shared libraries like the design system, and wrong for whole apps that need to ship on their own.

Decision How do the shell and the apps come together into one page?

Build-time packages

Each app is an npm package; the shell depends on fixed versions.

One build, one bundle, one deploy of the shell.

Pros

  • One build deduplicates everything; smallest bytes.
  • Type checking across apps; nothing to fail at runtime.

Cons

  • No deploy independence: every app release needs a shell release.
  • Lockstep upgrades of shared dependencies.

Server or edge composition (SSI, ESI, edge includes)

The page template holds placeholders like <esi:include src="/fragments/reviews" />.

A web server (SSI in nginx), a CDN (ESI in Varnish or Akamai), a layout service (Zalando's Tailor, or a Podium layout server), or an edge function fetches each fragment's HTML and stitches the page.

Pros

  • Real HTML on first byte: good for SEO and Largest Contentful Paint.
  • Each fragment is deployed and cached on its own.

Cons

  • Interactivity still needs each fragment's JavaScript, with the same sharing problems on the client.
  • A slow fragment delays the page unless the stitcher streams and enforces timeouts.
  • Client-side navigation between areas needs extra work.

Client-side runtime loading (Module Federation or import maps)

The shell loads in the browser, reads a manifest, and imports each app's entry at runtime.

Module Federation negotiates shared libraries; import maps map bare names like react to one URL.

Pros

  • Full deploy independence: a manifest change ships an app.
  • Shared React and design system downloaded once; smooth in-app navigation.

Cons

  • Nothing renders until JavaScript runs; weaker SEO without extra work.
  • Extra round trips and version skew happen at runtime, in the user's browser.

Iframes

Each app is a full page in its own iframe, ideally on its own origin.

The shell and apps talk only through postMessage.

Pros

  • Strongest isolation: separate DOM, CSS, globals, and (cross-origin) separate security context.
  • Any framework, any version, no conflicts.

Cons

  • Every app loads its own runtime; nothing is shared.
  • Modals can't cover the shell, heights must be synced, deep links and focus are awkward, screen readers see nested documents.

What we pick: We use client-side runtime loading for the logged-in product, with Module Federation as the loader and a framework-neutral mount contract. Public pages (product, search) use edge composition so the first HTML has content. Iframes are kept for the one case that needs a security wall, and npm packages for shared libraries only. Mixing methods by page type is normal and is the answer interviewers want to hear.

What real products have published

Public write-ups show each method in production. Engineers at IKEA have described composing pages at the edge with Edge Side Includes, so each team's HTML fragment is stitched in before the page reaches the browser. Zalando's Project Mosaic streamed fragments owned by different teams through Tailor, its open-source layout service. FINN.no built Podium, where a layout server fetches HTML fragments called podlets from each team's service.

On the client side, DAZN has described a small orchestrator that loads one vertical micro-frontend at a time. Spotify's earlier desktop client ran each page as a standalone app inside its own iframe, and in 2021 Spotify described moving desktop and web onto one shared codebase. Zalando also moved from fragments to a shared framework of React renderers to get consistency back. Companies move between methods as their teams and products change, which is one more reason to keep the app contract independent of the loading method.

Module Federation in practice

Module Federation started as a webpack 5 plugin, ModuleFederationPlugin. A build becomes a container: a remote lists the modules it exposes and the libraries it can share (shared), and a host lists the remotes it may load by name at runtime. The remote's build emits a small remoteEntry.js file, the remote entry, that the host loads first to reach everything else.

Module Federation is now also its own project. Module Federation 2.0 (the @module-federation/enhanced package) adds a runtime API you can call directly, such as loadRemote and registerRemotes, and a JSON manifest per remote, mf-manifest.json, that lists its entry, exposed modules, shared libraries, and files. It ships plugins for webpack, Rspack, and Vite; Rspack also has its own built-in plugin for the original feature set.

js
new ModuleFederationPlugin({
  name: "checkout",
  filename: "remoteEntry.js",
  manifest: true, // also emit mf-manifest.json
  exposes: {
    "./app": "./src/mount.ts", // exports mount and unmount
  },
  shared: {
    react: { singleton: true, requiredVersion: "^19.0.0" },
    "react-dom": { singleton: true, requiredVersion: "^19.0.0" },
    "@acme/design-system": { singleton: true, requiredVersion: "^7.2.0" },
  },
});
The checkout remote's build config, with the Module Federation 2.0 plugin for webpack or Rspack.

Notice what the remote exposes: not a React component but a module with mount and unmount functions. That is the contract. If the remote exposed a React component, the shell would need the same React to render it, and the Angular area could never join. The architecture chapter writes this contract down in full.

Import maps: the standards-based option

An import map is a JSON block in the page, <script type="importmap">, that tells the browser which URL a bare module name like react or @acme/checkout points to. All major browsers support it. Each app is built as native ES modules with shared libraries left as bare imports; the shell writes the map from the manifest; the browser does the rest.

html
<script type="importmap">
{
  "imports": {
    "react": "https://cdn.acme.com/libs/react@19.1.0/index.js",
    "@acme/search": "https://cdn.acme.com/search/3.8.1/main.js",
    "@acme/seller": "https://cdn.acme.com/seller/12.0.4/main.js"
  },
  "scopes": {
    "https://cdn.acme.com/seller/": {
      "react": "https://cdn.acme.com/libs/react@18.3.1/index.js"
    }
  }
}
</script>
One map decides every version on the page. Scopes give one app a different version of a dependency.

Import maps have no version negotiation: whatever the map says wins. That is simpler to reason about than Module Federation, and it puts all the version decisions in one place (the manifest service that writes the map). The cost is that libraries must be built as standalone ES modules, and the map should be in the page before the first module import runs, so it is best rendered into the HTML by the server.

Recent browsers (Chrome 127, Safari 18.4, Firefox 138, and later) also accept an integrity key in the map. It lists a Subresource Integrity hash for each module URL, so the browser refuses a module whose bytes don't match. The manifest service already knows every file it publishes, so it can write those hashes into the map; older browsers ignore the key.

Web components: a contract, not a loader

A web component (see the web-components lesson) can wrap an app: checkout registers <acme-checkout> and the shell puts the tag in the page. This gives a framework-neutral contract: attributes in, DOM events out, and an optional shadow root for CSS. It does not load code by itself; you still need one of the loaders above. And custom element names are global: two versions of a remote can't both define acme-checkout in one page, so version the tag name or keep one version per page.

Try the methods side by side. Switch the composition method and watch the bytes a user downloads after visiting three areas, how many React copies end up on the page, how isolated the apps are, and whether a team can ship alone.

Method
Visited
Composition with Module Federation, shared, after visiting search, product, checkout. By a rough model, the user downloads about 466 KB of compressed JavaScript, with 1 copy of React and 5 steps before the first render. Ships alone: yes. Isolation: soft: same page.shellshellMF runtimeReactdesign syssearchsearchproductproductcheckoutcheckoutJS, estimated: 466 KBReact copies: 1steps before render: 5ships alone: yesisolation: soft: same page
React and the design system come from the shell once; each area adds only its own code plus a small remote entry. Teams ship alone. Sizes are rough estimates from a simple model, not measurements.
The composition methods compared for our product. Bytes are rough estimates of compressed JavaScript after visiting search, product, and checkout, from the same model as the lab above.
MethodDeploy alone?React copiesIsolationFirst paintBytes (3 areas)
Build-time packagesNo1Low: one bundleFast with SSRabout 445 KB
Edge includes (SSI, ESI)YesDepends on client loaderLow to mediumFastest: HTML firstSame as its client loader
Module FederationYes1 if shared as singletonLow: same documentAfter JS, unless SSRabout 465 KB
Import mapsYes1 by the mapLow: same documentAfter JS, unless SSRabout 445 KB
IframesYes1 per frame, plus the shell'sHigh: separate documentsSlow: one page load per frameabout 730 KB

Check your understanding · Exercise 3

The checkout team wants to ship a hotfix at 4 pm without involving anyone else. Which composition methods allow that? Select all that apply.

The shell's job: route, load, mount, frame

The shell should be the smallest, most boring app in the company. It changes rarely, it is owned by a platform team, and if it breaks, everything breaks. Its job has four parts: map URLs to apps, know which version of each app is live, run each app through a lifecycle, and draw the shared frame. Anything else (product features, business logic, a global store of everyone's data) belongs in an app.

Top-level routing: URL prefixes to apps

Routing is split in two levels (the routing lesson covers client routers in general). The shell owns the first path segment: /search goes to search, /checkout to checkout, /sell to the seller app. Each app owns everything after its prefix with its own router. The shell never knows that /checkout/payment exists; checkout never knows about /search.

When the user clicks a link, two things can happen. If the link stays inside the current app (/checkout/address to /checkout/payment), the app's router handles it and the shell does nothing. If it crosses apps (/search to /checkout), the shell must unmount search and mount checkout.

Both apps change the URL through the History API, so the shell listens for popstate and wraps history.pushState and replaceState to notice changes (this is what single-spa does), then re-runs its route table. Longest prefix wins, so /sell/analytics can belong to a different app than /sell.

The manifest: which version is live

The shell's route table says which app owns a URL. The manifest says where that app's code is right now. Keeping them apart is the key to independent deploys: the shell's code holds no app versions at all, so shipping an app is a data change, not a code change. The manifest is a small JSON document served by a registry service, around 2 KB for our 12 apps.

json
{
  "schema": 3,
  "generatedAt": "2026-10-20T14:02:11Z",
  "apps": {
    "search": {
      "version": "3.8.1",
      "entry": "https://cdn.acme.com/search/3.8.1/mf-manifest.json",
      "routes": ["/search"],
      "owner": "team-discovery",
      "timeoutMs": 6000
    },
    "checkout": {
      "version": "5.2.0",
      "entry": "https://cdn.acme.com/checkout/5.2.0/mf-manifest.json",
      "routes": ["/checkout", "/cart"],
      "owner": "team-payments",
      "timeoutMs": 6000,
      "fallback": { "kind": "link", "href": "/legacy/checkout" }
    }
  }
}
The manifest the shell reads at startup. URLs point at immutable, versioned files.

Should routes live in the manifest or in the shell's code? In the manifest, an app can claim a new prefix without a shell deploy, but a typo could let one team claim / and hide everyone else. We keep routes in the manifest and have the registry validate them on publish: no overlaps with another team's prefixes, no root claim, and only prefixes on that team's allow list.

The lifecycle: load, bootstrap, mount, unmount

Every app moves through the same states, and the shell drives them. single-spa, the best known framework for this, requires each app to export three async functions, bootstrap, mount, and unmount, with an optional unload. We use the same idea in our own contract: bootstrap runs once after the code loads (set up a store, read config); mount renders into a DOM element the shell gives it; unmount must remove everything it added: DOM, listeners, timers, and subscriptions.

State machine for one app in the shell. Not loaded goes to loading when a route matches. Loading goes to not mounted when the code arrives, or to load error on a 404 or timeout. Load error goes back to loading on retry. Not mounted goes to mounted on mount, and mounted goes back to not mounted on unmount. Not mounted goes to broken if bootstrap or mount throws. Notes: bootstrap runs once, before the first mount; broken means the same code fails again, so there is no retry until a new version.not loadedloadingnot mountedmountedload errorbrokenroutecode ok404, timeoutretrymountunmountthrowsbootstrap runs once,before the first mountbroken: same code failsagain, so no retry
Load error and broken look alike to the user but differ for the shell: a failed download is worth retrying, a mount that throws will throw again with the same code.

Two states in the machine deserve attention. Load error happens when the code never arrived (404, network error, timeout); the shell may retry it on the next navigation. Broken happens when the code arrived but a lifecycle function threw; retrying the same code will fail again, so the shell shows the fallback until a new manifest version arrives. Telling these apart decides whether a retry button makes sense.

Unmount is the step teams get wrong. A React app that calls root.unmount() but forgot to remove a window resize listener leaks it; after 30 trips between search and checkout, 30 listeners fire on every resize, each holding the old app's memory. The shell can't fix that for them, but the contract test in the testing chapter can catch it.

Sequence diagram of a navigation from search to checkout, between the user, the shell, the CDN, and the checkout app. The user clicks Checkout and the shell's router sees a new owner. The shell imports checkout from the CDN. The CDN returns checkout's entry and chunks, which are immutable files. The shell calls mount with the slot element and the context. Checkout renders the payment form for the user.UserShellCDNCheckoutclick Checkoutimport checkoutentry + chunksmount(el, ctx)payment form
Before importing, the shell unmounts search and shows a skeleton. It never renders checkout's screens: it finds the owner, loads code by the URL in the manifest, and hands over a DOM element with mount(el, ctx).
ts
async function onRouteChange(path: string) {
  const next = findApp(path, manifest); // longest prefix wins
  if (next?.name === current?.name) return; // the app routes inside itself
  const token = ++navToken;
  if (current) await lifecycle.unmount(current, slot);
  if (!next) return renderNotFound(slot);
  renderSkeleton(slot, next);
  try {
    const app = await withTimeout(lifecycle.load(next), next.timeoutMs);
    if (token !== navToken) return; // the user navigated again
    await lifecycle.mount(app, slot, contextFor(next));
    current = next;
  } catch (err) {
    if (token !== navToken) return;
    report(err, { app: next.name, version: next.version });
    renderFallback(slot, next, err);
  }
}
The heart of the shell's router: find the owner, unmount the old app, mount the new one.

The navToken check handles a race: a user clicks Checkout, the remote is slow, and they click Search before it arrives. Without the check, checkout would mount on top of search 3 seconds later. Each navigation takes a number, and any older navigation that finishes late quietly gives up.

The shared frame

The shell renders the header, navigation, account menu, footer, and a toast area, and they stay mounted across app switches, so moving from search to checkout never blanks the header. The cart badge in the header is a small piece of state the shell owns but checkout updates (next chapters show how). Keep the frame thin: if the header grows a search box with autocomplete, that box should be a widget exposed by the search team and mounted by the shell, not search logic copied into the shell.

Check your understanding · Exercise 4

Which of these belong in the shell? Select all that apply.

Shared dependencies: one React or several

Each app is built alone, so by default each bundles everything it imports. Visit search, product, and checkout, and you download React DOM three times: roughly 40 to 60 KB compressed each, depending on the version, plus the design system three times. Worse than the bytes, React breaks when two copies meet.

A component rendered by one copy that calls a hook from another copy throws "Invalid hook call", and a context provider from one copy is invisible to consumers using the other. So sharing is not only an optimization. For any library that keeps state in module scope (React, a router, a store, the design system's theme context), it is a correctness rule.

Decision How do apps share libraries like React and the design system?

Share nothing

Each app bundles its own copy of every library.

Pros

  • Total independence: any app can upgrade anything any day.
  • No runtime version negotiation to debug.

Cons

  • Duplicate bytes on every area switch.
  • Two Reacts on one page if apps nest, with hook and context errors.
  • Design system state like theme or toasts splits per app.

Shell provides globals

The shell loads React and the design system and puts them on window; apps mark them external.

Pros

  • One copy, simple to understand.

Cons

  • The shell's version is the only version; every app is coupled to the shell's upgrade schedule.
  • No checking: an app built for a newer version just breaks at runtime.

Module Federation shared scope

Each build declares shared libraries with a semver range; at startup, the runtime picks one version per library from what the host and remotes offer.

singleton forces exactly one copy; requiredVersion states the range a build needs.

Pros

  • One copy when versions agree, automatic fallback to the app's own copy when they don't (for non-singletons).
  • Mismatches are detected and reported at runtime.

Cons

  • Negotiation is runtime behavior that is hard to reason about and to test.
  • A singleton mismatch only warns by default; the app runs on a version it wasn't built for.

Import map pins

The manifest service writes one URL per library into the import map.

Scopes give a legacy app a different version under its own path.

Pros

  • One place decides every version; easy to read and audit.
  • Scopes make exceptions explicit.

Cons

  • Libraries must be built as standalone ES modules.
  • No automatic negotiation: the platform team must check compatibility before changing a pin.

What we pick: We share React, React DOM, the router, and the design system as singletons through Module Federation, with the shell as the host that decides the version. Utility libraries (date formatting, charting) are shared without singleton, so a mismatch falls back to the app's own copy instead of breaking. The Angular area shares nothing with the React apps and only talks through the contract.

Shared React negotiation. On the left, three builds: the shell provides React 19.1.0, search needs a range of caret 19.0.0, and seller needs caret 18.3.0. All three register with the share scope in the middle, which holds one React: 19.1.0. On the right, the results: search uses 19.1.0 with no problem; seller as a plain singleton gets a warning and still uses 19.1.0; seller with strictVersion throws, so the shell shows its fallback.shellprovides 19.1.0searchneeds ^19.0.0sellerneeds ^18.3.0share scopereact: 19.1.0searchuses 19.1.0sellerwarns, uses 19.1.0seller, strictthrows: fallbackone copy of React on the page, chosen at runtime
A singleton means one copy for the whole page. Ranges decide whether that copy is acceptable; strictVersion decides whether a mismatch is a warning or a load error the shell can catch.

How singleton and requiredVersion behave

At startup, each build registers the versions it carries in a share scope, a registry of library versions available on the page. When code asks for react, the runtime picks a version. Without singleton, it picks the highest registered version that satisfies the asker's requiredVersion, or falls back to the asker's own copy.

With singleton: true, there is exactly one version for everyone. The runtime reuses a copy that is already loaded, or else the highest registered version; the shell loads React before any remote starts, so the shell's version wins. If it doesn't satisfy a remote's range, the runtime logs a warning and uses it anyway. Add strictVersion: true and the mismatch throws instead, which turns a subtle bug into a visible load error that the shell's fallback can handle.

What happens when the shell provides React 19.1.0 and remotes ask for different ranges.
Remote asks forsingleton onlysingleton + strictVersionnot singleton
^19.0.0Uses 19.1.0Uses 19.1.0Uses 19.1.0
^18.3.0Warns, uses 19.1.0Throws: remote fails to loadLoads its own 18.3 copy: two Reacts
^18.3.0 || ^19.0.0Uses 19.1.0Uses 19.1.0Uses 19.1.0

The last row is the trick for upgrades. A remote that declares both majors, and is tested against both, never blocks the shell. A shared singleton must be upgraded by the whole fleet, so we do it in two steps: first every remote widens its range and passes tests on both versions; then the shell flips the provided version. A dashboard that lists each remote's declared ranges, built from the manifests, shows who is blocking.

Version skew over time

Skew is not only React. Checkout built last week against design system 7.2 may now run beside the header on 7.4. Small skew is fine if the design system follows semver strictly and keeps its CSS stable within a major.

Large skew is where incidents come from: a remote that hasn't been rebuilt in eight months still declares ^6.0.0 of the design system. We set a policy: remotes older than 90 days or more than one major behind on a shared singleton get flagged on the dashboard, and the shell's next major upgrade won't wait for them; they fall back to their non-shared copy or get a fallback page.

Check your understanding · Exercise 5

The seller app declares react with singleton: true and requiredVersion: "^18.3.0". The shell provides React 19.1.0. What happens? Select all that apply.

Cross-app communication: keep it small

Apps need to talk: the product page adds an item and the cart badge in the header must go from 2 to 3, the seller app needs to know who is logged in, and search wants to send the user to checkout with a coupon applied. Every channel between apps is a contract that two teams must keep stable, so the goal is the fewest, narrowest channels that do the job.

The naive design is a shared global store: put the cart, the user, the search filters, and the checkout state in one Redux store that every app can read and write. Within a quarter, checkout reads state.search.lastQuery for a banner, search writes state.cart.items for a quick-add button, and the store's shape can't change without a meeting of four teams. Worse, every app now depends on the same store library and version. We recreated the monolith, with more network requests.

Decision How do apps share information with each other and with the shell?

The URL

Navigate with data in the path and query: /checkout?coupon=FALL10.

The receiving app parses its own URL.

Pros

  • Already public, bookmarkable, and survives reloads.
  • No runtime coupling: the sender doesn't even need the receiver to be loaded.

Cons

  • Only for navigation-time data; can't push live updates.

DOM custom events

The sender dispatches new CustomEvent("acme:cart-changed", { detail }) on window; listeners subscribe with addEventListener.

Pros

  • Built into the browser; works across frameworks, including the Angular app.
  • Fire and forget: no shared library.

Cons

  • No types or schema by default; no memory: a listener added later misses past events.

A small event bus from the shell

The shell passes ctx.events to each app on mount, with emit, on, and last(name).

Pros

  • Typed, versioned event names in the contract package.
  • Can replay the last value of an event to late subscribers, and can log every event for debugging.

Cons

  • One more API in the contract to keep stable.

Shared global store

One store library and one state tree that all apps read and write.

Pros

  • Familiar; any app can read anything.

Cons

  • Couples every app to one schema and one library version.
  • Hidden dependencies between teams; changes need cross-team agreement.

What we pick: The URL is the main contract between apps. For live updates we use the shell's small event bus, built on DOM custom events so non-React apps can join, with a handful of documented events. Shared state is limited to what the shell owns: the user, the session, the locale, and feature flags, passed to each app on mount. No global store.

Events as a contract

ts
export type ShellEvents = {
  "cart:changed@1": { itemCount: number };
  "user:changed@1": { userId: string | null };
  "toast:show@1": { kind: "info" | "error"; text: string };
};

export type EventBus = {
  emit<K extends keyof ShellEvents>(name: K, detail: ShellEvents[K]): void;
  on<K extends keyof ShellEvents>(
    name: K,
    fn: (detail: ShellEvents[K]) => void,
  ): () => void; // returns unsubscribe, call it in unmount
  last<K extends keyof ShellEvents>(name: K): ShellEvents[K] | undefined;
};
Published in @acme/shell-contract. Adding a field is fine; renaming one needs a new version of the event.

Three rules keep the bus healthy. Events describe facts that already happened (cart:changed), not commands to another team's app (checkout:open-drawer), so the sender doesn't depend on who listens. Payloads are small and stable: the item count, not the whole cart. And each event name carries a version, so the shell can emit both @1 and @2 during a migration.

Sequence diagram of add to cart, between the user, the product app, the cart API, the shell's event bus, and the shell's header. The user clicks Add to cart. The product app sends POST /cart. The cart API replies 200 with an item count of 3. The product app emits cart:changed version 1 on the bus. The bus delivers item count 3 to the header. The header re-renders and the badge shows 3.UserProductCart APIBusHeaderAdd to cartPOST /cart200, count 3cart:changeditemCount: 3badge shows 3
The product app never touches the header. It states a fact, cart:changed@1, after the server confirms; whoever cares listens. If the POST fails, no event is sent and the product app shows its own error.

Auth and session through the shell

Login is the one piece of state every app needs and no app should own. The shell runs the login flow and keeps the session. The best setup keeps the session in an HttpOnly cookie on the same site as the APIs, so apps make calls with credentials: "include" and never touch a token.

When APIs need a bearer token instead, the shell exposes ctx.auth.getToken(), which returns a fresh access token from memory and refreshes it when it is about to expire. Apps never store tokens themselves, so there is one refresh at a time instead of eight apps racing to refresh the same token.

Sequence diagram of an authenticated call, between the seller app, the shell, the auth API, and the orders API. The seller app calls getToken on the shell. The token is about to expire, so the shell sends POST /refresh to the auth API, which replies with a new token. The shell returns the token to the seller app, which calls GET /orders on the orders API and gets 200 with orders. Failure path: if refresh fails, the auth API replies 401, and the shell emits user:changed with a null user and sends the user to /login.Seller appShellAuth APIOrders APIgetToken()POST /refreshnew tokentokenGET /orders+token200 ordersif refresh fails401user: null, /login
Only the shell refreshes, so eight apps asking at once cause one refresh, not eight. On failure the shell decides for everyone: clear private data, then log in again with a return URL.

Notice the failure path: when refresh fails, the shell, not the app, decides what happens. It emits user:changed@1 with a null user so every app can clear private data, then sends the user to the login page with a return URL. Each app handling expiry its own way is how a product ends up with three different "session expired" screens.

Check your understanding · Exercise 6

Search wants checkout to start with a coupon applied when the user follows a promo link. What's the best channel? Select all that apply.

Isolation: one broken app must not break the page

Twelve teams share one document, one window, and one cascade. Without walls, a mistake in one app shows up in another team's area, and the wrong team gets paged. There are three kinds of leaks to stop: styles, JavaScript globals, and failures.

CSS: stopping styles from leaking

The classic incident: the seller team adds h2 { margin: 0 } to a reset file. It ships at 2 pm. Every h2 in search, product, and checkout loses its spacing the moment a user who visited the seller area navigates elsewhere, because the seller stylesheet stays in the page after unmount. Nobody on the seller team looks at checkout, so the bug lives for a day.

Decision How do we keep each app's CSS inside its own area?

Naming conventions

Every class starts with the app's prefix: .chk-summary, .srch-filter. No element selectors at the top level.

Pros

  • Zero tooling; works with any framework.

Cons

  • Only a convention: one forgotten rule leaks. Needs a lint rule to be real.

CSS Modules or CSS-in-JS with hashed names

The build renames .summary to something like .summary_x7f2a, unique per app build.

Pros

  • Class collisions become impossible by construction.
  • Familiar to React teams.

Cons

  • Doesn't stop element or global selectors in plain stylesheets.
  • Styles may stay in the page after unmount unless the app removes them.

Cascade layers

The shell declares @layer reset, ds, apps; once. Each app puts its CSS in @layer apps.

Pros

  • Defines who wins: the design system always beats the reset, and apps can override the design system on purpose.
  • One line of policy in the shell.

Cons

  • Doesn't scope selectors: two apps in the same layer still collide.
  • Unlayered CSS beats every layer (for normal, non-!important rules), so one app that skips the rule wins everything.

Shadow DOM

The app mounts inside an element with an attached shadow root; its styles live in that root.

Pros

  • Real isolation: selectors can't cross the boundary in either direction.

Cons

  • Inherited properties (color, font) and CSS custom properties still pass in, which is useful for tokens but surprising.
  • Portals for modals and tooltips render into document.body, outside the shadow root, and lose their styles.
  • Fonts must be declared in the main document.

What we pick: Hashed class names plus prefixes, enforced by a stylelint rule that rejects element and global selectors outside @layer reset, and cascade layers to set precedence. Apps remove their own style tags on unmount, and the contract test checks it. Shadow DOM is reserved for small embedded widgets where it fits; for whole apps with modals and portals it causes more trouble than it saves.

JavaScript globals and polyfills

Apps share one window. Two apps that each define window.analytics, or each register the custom element acme-date-picker, collide: the second customElements.define call throws a NotSupportedError because the name is taken. Polyfills are worse: one app ships an old Promise or fetch polyfill that replaces the browser's version for everyone. The rules: apps never write to window (a lint rule enforces it), never ship polyfills (the shell owns the polyfill set for our browser baseline), and namespace custom element names by app.

Failures: timeouts, fallbacks, and boundaries

A remote can fail in four ways, and each needs a different response. It can be slow (the CDN edge is cold or the network is bad), missing (a 404 because a deploy uploaded files to the wrong path), or it can throw while starting (in bootstrap or mount). Or it can crash later, during a render after a click.

Each failure mode, who notices, and what the user sees.
FailureWho noticesShell's responseUser sees
Slow loadShell's timerSkeleton until 6 s, then fallback; late code is discardedSkeleton, then "Checkout is taking longer than usual" with Retry
404 or network errorThe import promise rejectsLoad error: report, offer retry with backoffSame fallback; header and other apps keep working
Throws in bootstrap or mountShell's try/catch around the lifecycle callMark broken, report with app and version, unmount leftoversFallback; retry is hidden because the same code fails again
Crashes after mountThe app's own root error handlerApp renders its own error view and calls ctx.reportErrorThat app's error view; everything else intact

The last row has a trap. The shell's React error boundary only catches errors in the shell's own React tree. A remote calls createRoot itself, so its render errors happen in a separate root that the shell's boundary never sees. The contract therefore requires each app to bring its own boundary.

Our mount helper does it for React apps: it wraps the app in an error boundary and passes React 19's onUncaughtError root option, which reports anything that escaped. Boundaries only catch errors thrown while React renders, not in event handlers, timers, or promises. For those, the shell listens for error and unhandledrejection on window and attributes each error to an app by the script URL in its stack trace.

That attribution needs one setting. Our remotes load from cdn.acme.com, a different origin from the page, and the browser hides the details of errors from cross-origin scripts, reporting only the message "Script error". The CDN must send CORS headers, and the loader must request scripts in CORS mode (the crossorigin attribute; webpack's output.crossOriginLoading option sets it on chunks). The error-tracking lesson covers this in depth.

Timeouts have a catch too: a dynamic import() can't be cancelled. When the 6-second timer wins the race, the download keeps going. If the code arrives at second 9, the navigation token check from the shell's router makes the late result do nothing. Pick the timeout per app from real data: about three times its 95th percentile load time on mobile, so a normal slow load isn't cut off but a hung one is.

Sequence diagram of a slow remote, between the user, the shell, the CDN, and the error reporter. The user opens /checkout. The shell imports checkout from the CDN and shows the user a skeleton. Six seconds pass with no answer. The shell reports a timeout for checkout 5.3.0 to the reporter and shows the user a fallback with a Retry button. Later the code arrives from the CDN and is ignored. The user presses Retry, the code is now cached, and checkout mounts.UserShellCDNReporteropen /checkoutimport checkoutskeleton6 s, no replytimeout: 5.3.0fallback + Retrylate code: ignoredRetrycached: mounted
The timer can't cancel the download; it only stops the shell from waiting. The late code is ignored by the navigation check, but the browser keeps it in its cache, so Retry is fast.

Try it: make the checkout remote slow, missing, or crash during mount, and watch what the shell does and what the user sees. Then switch off the shell's protections to see why each one exists.

Checkout is
Protections
Checkout is slow: 9 s. Protections: timeout on, try/catch on, app error boundary on. The header still works. The slot shows: fallback + Retry. Timeline: 0.0 s skeleton, 6.0 s fallback, 9.0 s late, ignored. Reported: timeout: checkout 5.3.0.acme.com/checkoutheader: worksfallback + Retry0.0 s skeleton6.0 s fallback9.0 s late, ignorederror reporttimeout: checkout 5.3.0
The timer gives up at 6 seconds and shows a fallback with Retry. The code still downloads, so Retry is quick, and the late result is ignored.

Check your understanding · Exercise 7

The checkout remote throws inside its React render 10 seconds after mounting, when the user clicks Pay. Which statements are true? Select all that apply.

Architecture, data model, and the contract

Now put the pieces together. The design has three groups: what runs in the browser (the shell and the mounted apps), what serves code and configuration (the CDN and the registry), and what each team runs to ship (their pipeline). The boundaries follow ownership: everything the platform team owns is on one side of the contract, everything a product team owns is on the other.

This chapter covers the whole picture in three steps: the components and how data flows between them, the little data the shell keeps and where it lives, and the written contract every app implements.

Architecture of the micro-frontend shell. In the browser, the shell, owned by the platform team, has a router for URL prefixes, a lifecycle manager for load and mount, a loader running the Module Federation runtime, the frame with header and nav, and context services: auth, event bus, flags, and errors. Below it, the apps owned by product teams: search, not mounted, and checkout, mounted. The context services mount checkout with mount el ctx. Outside the browser: the CDN with immutable files sends code to the loader; the registry serves the manifest to the shell; team CI publishes and promotes versions in the registry; and app APIs, one BFF per team, which each app calls directly.BrowserShell (platform team)routerURL prefixeslifecycleload, mountloaderMF runtimeframeheader, navcontextauth, bus, flags, errorsApps (product teams)searchnot mountedcheckoutmountedmount(el, ctx)CDNimmutable filesregistryserves manifestteam CIpublish, promoteapp APIsone BFF per teamcodepromoteown API per app
The contract line runs between the two inner zones: everything above is the platform team's, everything below is a product team's. Product data goes straight from each app to its own API and never passes through the shell.
PartOwnerResponsibilityWhy the boundary is here
Shell runtimePlatformBoot, read manifest, top-level routing, run lifecycles, timeouts and fallbacks.Must be stable and small; changes to it affect every team.
Frame (header, nav, toasts)PlatformShared layout that stays mounted across app switches.Users must see one product; no app should be able to blank it.
Context servicesPlatformAuth and session, event bus, feature flags, locale, error reporter, passed to apps on mount.One owner per cross-cutting concern avoids eight competing token refreshes.
Remote loaderPlatformModule Federation runtime: share scope, loading remote entries, preloading.Isolates the loading technology; swapping to import maps later touches only this part.
Apps (remotes)Product teamsEverything inside their URL prefix: routes, UI, data fetching, state.The unit of independent deploy.
Registry servicePlatformStores versions per app, validates routes, serves the manifest, runs canaries.Turns a deploy into a data change with an audit trail.
CDNPlatformServes immutable versioned assets and the short-lived manifest.Teams upload files; nobody edits files in place.
Contract packagePlatformTypes for mount, the context, and events, plus a test harness.The compile-time half of the contract that runtime composition lost.

The shell's own bundle holds the runtime, the frame, and the context services: about 50 KB compressed, plus the shared React and design system it provides to everyone. Nothing product-specific lives in it. A good test is to ask, for any proposed shell feature, "would every app need this?" If only two apps would, it belongs in a shared library they import, not in the shell.

How data flows on a first visit

Follow a user who opens /checkout from a bookmark. The HTML comes from the edge with the manifest already inlined and a preload hint for checkout's remote entry. The shell boots, checks the session, and mounts checkout, which fetches its own data from its own backend-for-frontend; the shell never proxies product data. When the user places an order, checkout writes to its API and emits cart:changed@1; the header listens and updates the badge.

Data flow for a first visit to /checkout. 1: the edge sends HTML with the manifest inlined and preload hints to the shell. 2: the shell requests checkout's entry from the CDN. 3: the CDN sends checkout's code. 4: the shell mounts checkout with the element and context. 5: checkout sends GET /cart to its own BFF. 6: the BFF replies with JSON. 7: checkout emits cart:changed to the shell's header, which updates its badge. On writes, checkout posts to its BFF and then emits an event.edgeHTML + manifestshellboot, sessionCDNcheckout JScheckout BFFcart, orderscheckoutmounted appheadercart badge1. HTML2. GET entry3. code4. mount(el, ctx)5. GET /cart6. JSON7. cart:changed
The shell handles steps 1 to 4 only. From step 5 on, data moves between checkout and its own backend, and the shell learns about it only through events. On writes, checkout posts to its BFF first and emits the event after.

Two arrows deserve attention. First, product data flows directly between each app and its own API, never through the shell, so the shell can't become a bottleneck or a place where teams negotiate data shapes. Second, the only data the shell hands to apps is context: user, flags, locale, and the bus. That context is the whole surface the platform team must keep stable.

Think first

Should the shell have one backend-for-frontend that all apps call, to save round trips?

Data model: what the shell knows and where it lives

A shell holds little data, and that is the point. Product data belongs to the apps and lives inside them. The shell's model is about the apps themselves: which exist, which version is live, what state each is in, and the small context it shares. Where each piece lives decides what survives a reload, what can be cached, and what one team can break for another.

ts
type AppName = string; // "checkout"

type AppRecord = {
  name: AppName;
  version: string;          // "5.2.0", immutable once published
  entry: string;            // URL of the remote's mf-manifest.json
  routes: string[];         // ["/checkout", "/cart"]
  owner: string;            // "team-payments", used to route alerts
  timeoutMs: number;
  fallback?: { kind: "link" | "message"; href?: string };
};

type Manifest = { schema: number; generatedAt: string; apps: Record<AppName, AppRecord> };

type AppStatus =
  | "not-loaded" | "loading" | "bootstrapping" | "not-mounted"
  | "mounting" | "mounted" | "unmounting" | "load-error" | "broken";

type AppRuntime = { record: AppRecord; status: AppStatus; module?: AppModule; lastError?: string };

type ShellContext = { user: User | null; locale: string; flags: Record<string, boolean> };
The shell's types. Manifest types come from the registry; runtime state lives only in memory.
Where each piece of data lives, and why.
DataLives inLifetimeWhy there
ManifestRegistry, inlined into the HTML, then shell memoryOne page loadThe page must keep using the versions it booted with; switching mid-session mixes old and new code.
App runtime statusShell memory, a map keyed by app namePage lifetimePure runtime facts; nothing to persist.
Current area and sub-routeThe URLBookmarkableThe URL is the routing contract between shell, apps, and users.
User and sessionHttpOnly cookie; user profile in shell memorySessionOne owner; apps read it from context, never store it.
Feature flags and localeShell memory, from the shell BFFPage loadRead by many apps; evaluated once so all apps agree.
Last value of each eventEvent bus memoryPage lifetimeLets an app mounted later read the current cart count.
App data (cart, search results, orders)Inside each app: its server cache and storesUp to the appOwned by one team; other apps only see events about it.
Where each piece of data lives. On the server, the registry's manifest holds one AppRecord per app and is inlined into the HTML. In shell memory: a copy of the manifest, fixed for the page; the AppRuntime map with each app's status and module; the ShellContext with user, locale, and flags; and the event bus with the last value per event. In the browser: the URL with the area and sub-route, and an HttpOnly cookie for the session. Inside each app: its server cache, like cart and orders, and its own stores for forms and UI state.serverManifestAppRecord per appshell memorymanifest copyfixed for the pageShellContextuser, locale, flagsAppRuntime mapstatus, moduleevent buslast value per eventbrowserURLarea + sub-routeHttpOnly cookiesessioninside each appserver cachecart, ordersapp storesforms, UI stateowned by one team each
The shell's memory holds facts about apps, not product data. Product data stays inside the app that owns it, and other apps see it only through events.

One rule in that table prevents a whole class of bugs: a page keeps the manifest it booted with. If the shell polled the manifest and hot-swapped checkout from 5.2.0 to 5.3.0 while the user was on the payment step, the new code might expect a different state shape or a different API. Instead, a new manifest takes effect on the next full page load.

If an urgent rollback must reach open tabs, the shell can poll a tiny "minimum versions" endpoint every few minutes and, when the running version is below the minimum, swap the app on the next area switch, never mid-flow.

Check your understanding · Exercise 8

Where should these pieces of data live? Select all that apply.

Interface: the contract between shell and apps

With runtime composition, the contract replaces the type checker of a single build, so it must be small, written down, and versioned. It has three parts: the module every app exposes, the context the shell passes in, and the registry API that teams call to publish. All three live in the @acme/shell-contract package, which apps install as a normal build-time dependency.

What every app exposes

ts
export type AppModule = {
  contractVersion: 2;
  bootstrap?(ctx: BootContext): Promise<void>;
  mount(el: HTMLElement, ctx: MountContext): Promise<void>;
  unmount(el: HTMLElement): Promise<void>;
};

export type MountContext = {
  basePath: string;                 // "/checkout": the app routes below it
  user: User | null;
  locale: string;
  flags: Readonly<Record<string, boolean>>;
  auth: { getToken(): Promise<string> };
  events: EventBus;
  navigate(to: string): void;       // cross-app navigation goes through the shell
  reportError(err: unknown, extra?: object): void;
};
Framework-neutral: the Angular app implements the same three functions.

Each field earns its place. basePath lets the same app be mounted under a different prefix without code changes. navigate exists so an app never needs to know whether another area is a remote, a legacy page, or an iframe: it asks the shell to go to /account, and the shell decides between a client-side switch and a full page load. contractVersion lets the shell support two contract versions during a migration and refuse remotes that are too old with a clear error.

tsx
export function reactApp(App: ComponentType<{ ctx: MountContext }>): AppModule {
  const roots = new WeakMap<HTMLElement, Root>();
  return {
    contractVersion: 2,
    async mount(el, ctx) {
      const root = createRoot(el, {
        onUncaughtError: (error) => ctx.reportError(error, { phase: "render" }),
      });
      root.render(
        <AppErrorBoundary ctx={ctx}><App ctx={ctx} /></AppErrorBoundary>,
      );
      roots.set(el, root);
    },
    async unmount(el) {
      roots.get(el)?.unmount();
      roots.delete(el);
    },
  };
}
The helper React apps use, so no team rewrites the lifecycle or forgets the error boundary.

The registry API

Teams never edit the manifest by hand. Their pipeline calls the registry, which validates and records every change. Publishing a version and making it live are separate steps, so a version can be uploaded, smoke-tested, and only then promoted.

http
POST /apps/checkout/versions
{ "version": "5.3.0", "entry": "https://cdn.acme.com/checkout/5.3.0/mf-manifest.json",
  "routes": ["/checkout", "/cart"], "contractVersion": 2,
  "shared": { "react": "^19.0.0", "@acme/design-system": "^7.2.0" } }
-> 201 { "version": "5.3.0", "checks": { "routes": "ok", "shared": "ok", "entryReachable": "ok" } }

PUT /apps/checkout/live
{ "rollout": [ { "version": "5.2.0", "weight": 95 }, { "version": "5.3.0", "weight": 5 } ] }
-> 200 { "manifestEtag": "\"m-81f2\"" }

GET /manifest.json
-> 200  Cache-Control: no-cache   CDN-Cache-Control: max-age=5   ETag: "m-81f2"
Publish, then promote. Canary and rollback are the same promote call with different weights.

Errors use one shape so pipelines can print them: { "error": "route_conflict", "message": "/cart is owned by team-payments", "field": "routes" }. The registry rejects a version whose shared ranges can't be satisfied by the shell's provided versions, whose entry URL returns anything but 200, or whose routes overlap another app's. Those three checks catch most broken deploys before any user sees them.

Check your understanding · Exercise 9

Why does the contract expose mount(el, ctx) instead of exporting a React component? Select all that apply.

Performance: waterfalls, duplicates, and budgets

Client-side composition has a built-in performance problem: every step waits for the one before. Take a mid-range phone on 4G with 150 ms of round-trip time, opening /checkout cold. The browser fetches the HTML, then the shell's JavaScript, which fetches the manifest, which names checkout's mf-manifest.json, which names the remote entry, which loads the exposed module, which finally fetches the cart.

That is seven sequential steps. At about 150 ms of waiting each, plus download and parse time, the cart data starts loading after more than a second, and the Largest Contentful Paint lands near 2.8 seconds, past our 2.5-second budget.

Loading waterfall for a cold visit to /checkout on 4G. Before: HTML from 0 to 0.3 seconds, shell 0.3 to 0.7, manifest 0.7 to 0.9, the remote's mf-manifest 0.9 to 1.1, entry 1.1 to 1.3, chunk 1.3 to 1.6, cart API 1.6 to 1.9, each waiting for the one before, and Largest Contentful Paint at 2.8 seconds. After: HTML 0 to 0.3, then the shell 0.3 to 0.7 and checkout's preloaded code 0.3 to 0.8 in parallel, and the cart API through an early loader from 0.75 to 1.05. Largest Contentful Paint at 1.6 seconds.beforeHTMLshellmanifestremote mfentrychunkcart APILCP 2.8 safterHTMLshellcheckoutcart APILCP 1.6 s
Seven steps in a row become three: the HTML, then the shell and checkout's code in parallel, then the cart data. The bytes barely change; what changes is how long the browser sits waiting for the previous answer. Timings are a model, not a measurement.

Flattening the waterfall

  1. Inline the manifest. The edge renders the HTML with the manifest in a script tag of type application/json. That removes one round trip, and the manifest is always consistent with the HTML.
  2. Preload the active app from the server. The edge knows the URL, so it knows checkout will be needed. It adds preload hints for the shell and for checkout's remote entry and first chunk (Module Federation 2.0's per-remote manifest lists them): <link rel="preload" as="script"> for classic scripts like a default remoteEntry.js, or <link rel="modulepreload"> for builds that output ES modules. The browser fetches them in parallel with the shell instead of after it.
  3. Serve every app from one CDN hostname. Putting the shell, every remote, and the manifest behind cdn.acme.com means one DNS lookup and one TLS connection, reused for every file. If a remote must live elsewhere, add <link rel="preconnect"> for it.
  4. Start the data early. An app can expose a tiny loader module next to its app module. The shell calls it as soon as the route is known, in parallel with loading the UI code, the same idea as route loaders in modern routers.

With those four changes the chain becomes three steps: the HTML; then the shell and checkout's code in parallel; then the cart request, which the shell starts through checkout's loader as soon as it boots. The same visit paints in about 1.6 seconds in our model. The shell still costs more than a monolith, but the page now waits on three steps in a row instead of seven.

Preloading the likely next app

Switching areas also waits for code. The shell can guess the next area: when the user hovers or focuses the Checkout link for more than about 100 ms, it calls the loader's preload for checkout (fetch the entry and chunks without running them).

After the first screen is idle, it can preload the one or two most common next areas from analytics, for example cart after product. It skips this when the user has asked to save data (navigator.connection.saveData, reported by Chromium browsers), and it never preloads more than two apps, because each costs real bytes on a metered plan. The code-splitting lesson covers the same trade at the level of routes.

Finding and removing duplicate code

Sharing React fixes the biggest duplicate, but each team also bundles its own helpers. A weekly job downloads every live remote's stats file and lists packages that appear in three or more remotes. In a typical audit, a date library, a validation library, and an icon set show up in most apps, each 10 to 30 KB compressed. The fix depends on the package: stateless utilities can stay duplicated if small, become non-singleton shared modules if large, or move into the design system bundle if they are part of the look (icons, for example).

Budgets per team

In a monolith, one team watches the bundle. With twelve teams, nobody does unless the budget is per team and enforced where each team works: in its own pipeline. Here is how we split it.

Compressed JavaScript budgets. Shared modules are counted once, in the shell's line.
PartBudgetEnforced by
Shell runtime and frame60 KBShell pipeline
Shared: React, React DOM, router, design system150 KB, cached across all areasPlatform review on upgrades
Each app, first route120 KBThe app's pipeline fails the build above it
Each app, any later route chunk60 KBThe app's pipeline
Dashboard widget40 KBThe owning team's pipeline

Bytes are only the input. The output is measured in the field: Largest Contentful Paint and Interaction to Next Paint per area, tagged with the app and version (see the observability chapter). A team whose area's INP gets worse after a release sees it on their own dashboard the same day.

Check your understanding · Exercise 10

A cold load of /checkout waits on HTML, then the shell, then the manifest, then checkout's entry, then its chunk, then the cart API. Which changes shorten the critical path? Select all that apply.

Deployment: immutable files and the manifest as a switch

Independent deploys are the reason this whole system exists, so the deploy path must be fast, safe, and boring. The naive way is to upload checkout's new build over the old files at cdn.acme.com/checkout/remoteEntry.js.

Three things go wrong. Browsers and CDN edges hold the old file for a while, so users get a mix of old entry and new chunks. A user with checkout open loads a lazy chunk that no longer exists and gets a ChunkLoadError. And rollback means re-uploading the old build and waiting for caches again.

Decision How does a team ship and roll back its app?

Overwrite files in place

Upload the new build to a fixed path; purge the CDN.

Pros

  • Simple; no registry needed.

Cons

  • Mixed versions during cache expiry; broken lazy chunks in open tabs; slow rollback.

Immutable versioned files plus a manifest

Every build uploads to a new path like /checkout/5.3.0/; files never change after upload.

Going live means pointing the manifest at the new path.

Pros

  • No mixed versions: a page uses one version from end to end.
  • Rollback is pointing the manifest back, which takes seconds.
  • Canaries are a weight in the manifest.

Cons

  • Needs a registry and a cleanup policy for old versions.
  • The manifest becomes a critical dependency that must be highly available.

What we pick: Immutable versioned files and a manifest. The deploy uploads files, the registry promotes a version, and nothing else changes. This is the same idea as content-hashed assets in any single app, applied one level up.

Sequence diagram of a deploy, between the team's CI, the CDN, the registry, and the smoke tests. CI uploads the 5.3.0 files to the CDN. CI posts the new version to the registry. The registry builds a candidate manifest and sends it to the smoke tests. The smoke tests reply that 15 journeys are green. CI sets the live rollout to a 5 percent canary. The registry purges /manifest.json on the CDN.Team CICDNRegistrySmoke testsupload 5.3.0 filesPOST versioncandidate manifest15 journeys greenPUT 5% canarypurge manifest
Nothing is overwritten and the shell is never rebuilt. A release is two data changes in the registry, each checked before it takes effect.

Cache headers: long for files, short for the switch

Two kinds of files, two opposite cache policies (the http-caching and cdn-caching lessons explain the headers in depth). Versioned assets never change, so they get Cache-Control: public, max-age=31536000, immutable: a year, and the browser doesn't even revalidate on reload. The manifest and the HTML that inlines it are the switch, so they must update fast.

For browsers, the manifest gets Cache-Control: no-cache with an ETag: the browser asks every time but gets a tiny 304 Not Modified when nothing changed. no-cache binds the CDN too, so to let the CDN absorb traffic spikes we add a CDN-only header, CDN-Cache-Control: max-age=5, stale-if-error=86400 (Fastly reads Surrogate-Control instead). The CDN keeps the manifest for 5 seconds, serves the last good copy if the registry is down, and the registry purges it on every promote.

ResourceCache-ControlWhy
/checkout/5.3.0/*.js, *.csspublic, max-age=31536000, immutableThe path changes with every version, so the content at a path never does.
/manifest.jsonno-cache, plus CDN-Cache-Control: max-age=5, stale-if-error=86400It is the switch; rollback must reach new page loads within seconds.
HTML for app pagesprivate, no-cacheIt inlines the manifest and may contain a user-specific canary choice.

Canary and rollback

A canary sends a small share of users to the new version first. The registry stores weights per app (95 percent on 5.2.0, 5 percent on 5.3.0) and the edge picks a version per user by hashing a stable id, such as the user id or a first-party cookie, into a bucket from 0 to 99. Hashing keeps each user on the same version across page loads, so nobody bounces between versions.

The team watches error rate and Web Vitals for 5.3.0 against 5.2.0 on the same dashboard; if the new version's error rate is clearly higher, the registry sets its weight to 0 automatically. That is the rollback: one data change, live on the next page load.

Old versions must stay on the CDN for a while after they stop being live. A user who opened checkout on 5.2.0 an hour ago still loads 5.2.0's lazy chunks when they click to the payment step. We keep every version for 30 days after its last promotion, which costs little storage and removes ChunkLoadError from rollouts. If a version must be pulled for security reasons, the minimum-versions endpoint from the architecture chapter's data model makes open tabs switch on their next area change.

State machine for one app version. Uploaded goes to published when the registry checks pass, or to rejected when they fail. Published goes to canary when the smoke tests are green. Canary goes to live at weight 100, or to rolled back when errors go up. Live goes to retired when a newer version replaces it. Notes: rolled back means weight 0 with files kept; retired files stay 30 days, then are deleted.uploadedpublishedcanaryliverejectedrolled backretiredchecks oksmoke greenweight 100checks failerrors upreplacedrolled back: weight 0,files keptretired: files stay 30 days,then deleted
Every arrow is a registry change, and none of them touches files on the CDN. That is why rollback is as fast as a canary: both just change a weight.

Check your understanding · Exercise 11

Checkout 5.3.0 is on a 5 percent canary and its error rate jumps. Which statements describe a good rollback? Select all that apply.

Consistency and testing across teams

Users don't care that checkout and search are built by different teams. If the buttons differ by 2 pixels and the date formats disagree, the product feels broken. In a single build, consistency comes from sharing code by default. Here it must be designed in, and the same goes for testing: nothing checks that the shell and twelve remotes work together unless we build that check.

One design system, shared at runtime

The design system (see the design-systems lesson) is the main tool for consistency: design tokens for color, spacing, and type as CSS custom properties, plus components like Button and Dialog. We share it as a Module Federation singleton so the page has one copy, one theme, and one toast and dialog layer.

The tokens are defined once by the shell on :root; because custom properties inherit, they reach every app, even ones inside a shadow root. When the brand color changes, the shell ships new tokens and every area changes on the next page load, with no app redeploy.

The design system follows strict semver: a minor version may add components but must not change how existing ones look or behave. Breaking changes go in a major, which follows the two-step singleton upgrade from the shared dependencies chapter. Teams that need a component the design system doesn't have build it locally and propose it upstream, so differences stay temporary instead of permanent.

Contract tests between shell and remotes

Every remote's pipeline runs a contract harness from @acme/shell-contract: a fake shell that loads the built remote the way production does, with the shell's real shared versions, and checks the contract. It mounts the app into a slot, checks that something rendered, unmounts it, and checks that the slot is empty, no window listeners were left behind, no global variables were added, and no style tags remain. It also checks that the app only emits events listed in the contract types. A remote that fails can't be published to the registry.

The shell's pipeline runs the same harness the other way round: it loads the currently live version of every remote against the new shell build. If a shell change would break checkout 5.2.0, the shell team finds out before release, not from checkout's on-call.

End-to-end smoke tests per composition

Contract tests check each pair. A few end-to-end tests check the whole composition: the exact set of versions a manifest would put live. When a team publishes, the registry builds a candidate manifest (current live versions plus the new one) and runs about 15 Playwright journeys against staging with that manifest: sign in, search, open a product, add to cart, check out, open the seller dashboard.

Only a green run allows the promote. Keep this suite small and stable; each team's own deep end-to-end tests stay in its own pipeline.

Check your understanding · Exercise 12

The search team's new version passes all its own unit and end-to-end tests, but it leaves a scroll listener on window after unmount. Which test catches it before users do? Select all that apply.

Observability, SSR, and SEO

With twelve teams, "the site has errors" is useless. The first question in any incident is "whose code?", and the shell must answer it automatically. The error-tracking lesson covers the tools; here is what is specific to a shell.

Every error tagged by its owner

  • Through the context. Errors reported with ctx.reportError already know their app; the shell adds app, version, and owner tags from the manifest before sending.
  • Through the stack trace. For errors caught by the global error and unhandledrejection handlers, the shell reads the script URLs in the stack. Because every file lives under /checkout/5.3.0/, the path says which app and version threw.
  • Through releases. Each app uploads its source maps under a release name like checkout@5.3.0, so stack traces map back to that team's source, and the error tracker routes alerts to the owning team.
  • Through performance data. Web Vitals are reported with the area that was mounted. For slow interactions, the Long Animation Frames API (PerformanceLongAnimationFrameTiming, in Chromium browsers) lists the script URLs that ran, which again map to an app and version.

The shell also reports its own events: manifest fetch failures, timeouts per app, load errors, broken apps, and how long each mount took. A dashboard with "time to mounted" per app at the 75th percentile makes slow remotes visible before users complain.

SSR and SEO with client-side composition

A client-composed page sends an almost empty HTML document; the content appears only after the shell, the manifest, and the remote run. Google can render JavaScript, but rendering may happen later than crawling, other crawlers and link previews often don't run JavaScript at all, and Largest Contentful Paint suffers for users anyway. For logged-in areas like checkout and seller tools, this doesn't matter. For product and search pages, it does.

So public pages use server or edge composition. The edge fetches each fragment's server-rendered HTML (product details from the product team, reviews from the reviews team) with a per-fragment timeout and streams the page; each fragment then loads its JavaScript to become interactive, sharing React through the same runtime as the logged-in shell.

If a fragment misses its timeout, the edge sends a placeholder and the fragment renders on the client. This is more work for the platform team, which is why we limit it to the pages where search traffic and first paint matter most.

Check your understanding · Exercise 13

An error spike appears after 3 pm. Which signals let the shell route it to the right team automatically? Select all that apply.

Running it in the interview

This question rewards judgment more than mechanics. An interviewer who asks it usually works somewhere that has a shell, or regrets one. They want to hear that you know the cost, can pick a composition method for a reason, and have thought about the day a remote fails. Here is one way to spend 60 minutes.

MinutesWhat to doWhat to draw or say
0 to 7Scope: teams, deploy frequency, frameworks, SEO, trust."With 12 teams shipping daily, independence is worth paying for. If it were 3 teams, I'd propose a modular monorepo."
7 to 17Composition choice.The three-times picture: build, edge, client. Pick client-side runtime loading for the app and edge composition for public pages.
17 to 30The shell: routing, manifest, lifecycle, contract.Architecture boxes, the manifest JSON, the mount(el, ctx) contract, the lifecycle states.
30 to 40Shared dependencies and communication.Singleton React with version ranges; URL first, then a small event bus; auth owned by the shell.
40 to 50Failure and isolation.The four failure modes and the shell's response to each. CSS rules. Error boundaries per app.
50 to 60Deploys, performance, observability.Immutable paths plus manifest switch; waterfall and how to flatten it; errors tagged by app.

Draw the architecture diagram early and keep adding to it. The manifest is the most important box on the board: point at it whenever you talk about deploys, canaries, rollbacks, routing ownership, or version checks. Interviewers remember a candidate who explains five features with one mechanism.

Follow-up questions to expect

Think first

What if the manifest service is down?

Think first

The dashboard shows four widgets from four teams. How does that change the shell?

Think first

How would you move from today's monolith to this design without a big-bang rewrite?

Check your understanding · Exercise 14

The interviewer asks: "Why not just make every app an iframe? Then nothing can leak." Which points belong in a strong answer? Select all that apply.

Summary

  • Micro-frontends buy independent deploys and pay for them with runtime complexity, duplicate-code risk, and a platform team. With a few teams on one release train, a modular monorepo is the better answer.
  • Split vertically by area and URL prefix, one team per area. The shell owns the first path segment; each app owns everything below it.
  • Mix composition methods by page type: client-side runtime loading for the logged-in app, edge composition for pages that need SEO and fast first paint, iframes only where trust requires a security wall.
  • The contract is mount(el, ctx) and unmount(el), framework-neutral, versioned, and checked by a test harness, because no single build checks it any more.
  • Share stateful libraries like React and the design system as singletons with semver ranges; upgrade them in two steps so no remote blocks the fleet.
  • Communicate through the URL first, a small typed event bus second, and shared context owned by the shell (user, session, flags, locale). Never a global store.
  • Expect partial failure: per-app timeouts, fallbacks, separate error boundaries in every app root, and errors tagged with app, version, and owner.
  • Deploy immutable versioned files and make the manifest the switch: canary by weight, rollback in seconds, immutable caching for files, no-cache for the manifest.
  • Flatten the loading waterfall: inline the manifest, preload the active app's code, start its data early, and give every team its own enforced budget.

Check your answer

Tick what your own design covered. Pick the level you're interviewing for: each level includes the ones before it.

Junior 0 of 3
Mid-level 0 of 4

References

Concepts behind it

Try next