cf.completefrontend
My progress
FUNDAMENTALS · MODULE 17

Micro-frontends

Splitting one web app into parts that separate teams build and deploy on their own, then combining them at build time, on the server, or in the browser: how each approach works, what it costs in bytes and complexity, and why most teams should not do it.

The problem: too many people changing one app

Picture an online travel company. Five years ago, eight engineers built its website as one React app in one repository. Today 120 frontend engineers work on it, in 14 teams: search, hotel pages, flights, checkout, loyalty, account, help, and more. It is still one app, built by one pipeline and released as one bundle.

Every team feels the pain. A full build and test run takes 40 minutes. A release goes out twice a week, and it carries changes from all 14 teams, so when something breaks, everyone's work is rolled back together. The checkout team has a one-line fix for a payment bug and waits three days for the next release train.

Upgrading React is worse. One team wants the new version for a feature it needs. But all 14 teams share the same node_modules, so the upgrade needs every team to test and fix its code in the same sprint. It slips for a year.

What a micro-frontend is

A micro-frontend is a slice of a web app that one team builds, tests, and deploys on its own, and that is combined with other slices into one product the user sees as a single site. The travel site could become search, hotels, flights, and checkout as four micro-frontends, put together by a thin shell (also called the host or container) that owns the page frame, login, and top-level routing.

The idea is the frontend version of microservices, and it answers the same question: how do many teams change one product without blocking each other? The answer is to draw hard lines through the code so each team owns everything on its side of a line, from the repository to the deploy button.

Before and after. Before: the search, hotels, and checkout teams all feed one build that makes one release, with a 40 minute pipeline where one bug blocks everyone. After: each of the three teams has its own pipeline and ships alone, in about 5 to 20 minutes.Before: one appAfter: three slicessearchsearchpipelinehotelshotelspipelinecheckoutcheckoutpipelineone buildone release40 min pipeline,one bug blocks everyone5 to 20 min each,every team ships alone
Same teams, same product. On the left every change waits for one shared release; on the right each team's work reaches users on its own schedule, which is the whole point of the split.

Conway's law: code follows the org chart

In a 1968 paper, Melvin Conway observed that organizations design systems that copy their own communication structure. This is Conway's law. His example: eight people were asked to write a COBOL compiler and an ALGOL compiler, five got COBOL and three got ALGOL, and the COBOL compiler ran in five phases while the ALGOL one ran in three.

If 14 teams share one frontend, the code still grows 14 regions. But the build, the release, and the dependency list stay shared, and every shared thing needs a meeting.

Micro-frontends make the team lines official. Each team gets a part of the product (a vertical slice: its pages, its UI, often its own backend for frontend, a small server layer built for one frontend), its own repository or folder, its own pipeline, and its own release schedule.

What teams get from splitting

  • Independent deploys. Checkout ships its payment fix in 20 minutes, without waiting for search. This is the main reason, and the one to test any design against: if a team still has to coordinate a release with another team, you paid the cost without getting the benefit.
  • Smaller builds. Each team builds and tests only its own slice. A 40-minute pipeline becomes several 5-minute pipelines.
  • Contained failure. A bug in the recommendations widget can be caught by an error boundary (a React component that catches errors thrown while its children render) and hidden, while checkout keeps working. A broken deploy rolls back one slice, not the whole site.
  • Independent upgrades. In theory, each team picks its library versions. In practice this is limited, as the chapter on shared dependencies shows, but a team can at least move first and others can follow.

Notice what is missing: anything for the user. One well-built app is almost always smaller and faster than the same product split five ways. Micro-frontends trade bytes and complexity for team speed, so they only make sense when coordination between teams is the real bottleneck.

Check your understanding · Exercise 1

Which of these are good reasons to split a frontend into micro-frontends? Select all that apply.

The mental model: a shell, some slices, and a few contracts

Think of a food court. Each stall has its own kitchen, its own staff, and its own menu, and it can change a recipe without asking anyone. The food court provides the entrance, the seats, the signs, and the trays. It works because every stall agrees on a few shared things: where the trays go, how customers pay, which sign style to use.

A micro-frontend architecture has the same three parts. The shell is the food court building, the slices (micro-frontends) are the stalls, and the contracts are the small set of shared rules that let the stalls look and act like one place. Most of the hard work, and most of the failures, live in the contracts, not in the slices.

A layered picture of one browser page. On top, the shell, which owns the frame, top-level routes, login, flags, and error boundaries. Below it, three slices: flights, checkout, and loyalty, each owned by its own team. Below them, the four contracts every slice follows: mount, route, events, and shared code. At the bottom, the shared libraries: React, React DOM, and the design system.One browser pageshell (host)frame, top-level routes, login, flags, error boundariesflightsflights teamcheckoutcheckout teamloyaltyloyalty teamthe four contractsmountrouteeventsshared codeshared: react, react-dom, design system
The shell is thin and the slices are thick. What holds it together is the narrow band of contracts in between, plus the few libraries everyone agrees to share.

What the shell owns

The shell is the first thing that loads. A good shell is thin and changes rarely, because every team depends on it and a shell bug breaks every page. It usually owns:

  • The page frame: header, navigation, footer, and the empty region where a slice will render.
  • Top-level routing: reading the first part of the URL (/flights, /checkout) and picking which slice to load.
  • Cross-cutting services: login state, the user's locale and currency, feature flags, analytics setup, and error reporting.
  • The loading rules: where each slice's code lives, which shared libraries are offered, and what to show while a slice loads or if it fails.

What a slice owns

A slice is usually a vertical slice of the product: all the pages and components for one business area, from the UI down to its API calls, often with its own backend for frontend. Split by business area, not by technical layer. A "header team" and a "forms team" would have to change together for every feature; a "checkout team" can ship a checkout feature alone.

Some slices are not whole pages but fragments: a "recommended hotels" carousel that the hotel team owns and the flights page shows. Fragments are useful but costly, because now two teams' code shares one screen, one layout, and one moment of loading.

The four contracts

Every split needs answers to four questions. Write them down before writing code; they are the public API between teams, and they are much harder to change later than any component.

ContractThe question it answersTypical answer
MountHow does the shell start and stop a slice?A function like mount(element, props) that returns unmount(), or a custom element like checkout-app.
RouteWhich URLs belong to which slice?The shell owns the first path segment; the slice owns the rest.
EventsHow do slices tell each other things?A few named browser events, like cart:changed, with a typed payload.
Shared codeWhich libraries and versions are shared?React, React DOM, and the design system, at versions a platform team publishes.
ts
// The mount contract every slice implements
export type SliceProps = { basePath: string; user: User | null; locale: string };

export type Slice = {
  mount(el: HTMLElement, props: SliceProps): () => void; // returns unmount
};

// checkout team, src/entry.tsx
export const slice: Slice = {
  mount(el, props) {
    const root = createRoot(el);
    root.render(<CheckoutApp {...props} />);
    return () => root.unmount();
  },
};
A framework-neutral mount contract. The shell does not need to know the slice uses React; it only calls mount and later the returned function.

Check your understanding · Exercise 2

Which of these belong in the shell rather than in a slice? Select all that apply.

Where the pieces are put together

Slices are built separately, but the user must get one page. So somewhere between the teams' repositories and the user's screen, the pieces are joined. There are three places to do it: at build time, on the server or edge, or in the browser at runtime. Each place gives a different answer to the question that matters most: can a team deploy without anyone else rebuilding?

A flowchart. The team repositories for search, hotels, and checkout can be joined in three places. At build time, the shell bundles them as packages into one bundle, and the shell must redeploy for any team to ship. On the server or edge, a layout service stitches HTML fragments into one page, and teams deploy alone. In the browser at runtime, the shell loads remote code into one live page, and teams deploy alone.team repossearch, hotels, checkoutbuild timeshell bundles packagesone bundleshell redeploys to shipserver or edgestitches fragmentsone HTML pageteams deploy alonebrowser runtimeshell loads remotesone live pageteams deploy alonethe test: can a team ship without a shell rebuild?
All three produce one page for the user. The difference is who has to rebuild when one team changes something.

Build time: slices as packages

Each team publishes its slice as a versioned npm package, for example @travel/checkout@4.2.0. The shell lists the packages in its package.json, installs them, and bundles everything into one app. The result is one normal, well-optimized bundle: one copy of React, shared chunks, tree shaking across all slices.

The catch is that it is not really independent. To ship a checkout fix, the checkout team publishes 4.2.1, then someone bumps the version in the shell and the shell deploys. Teams get separate code and separate tests, but the release is shared again. This is a fine setup, often the best one, but call it what it is: a modular monolith, which the modular frontend architecture lesson covers in depth.

Server or edge: stitching HTML

Each team runs a small service that returns an HTML fragment. A server or CDN edge in front of them fetches the fragments and stitches them into one page before sending it. The old tools are SSI (server side includes, a <!--#include virtual="/cart" --> comment that a web server like nginx replaces) and ESI (edge side includes, an esi:include tag that some CDNs and proxies like Varnish understand). Modern versions use an edge function or a Node.js layout server that streams fragments in order.

Server composition gives a fast first paint and works before any JavaScript loads, which matters for search engines and slow phones. Each fragment can have its own cache lifetime: the header for an hour, the cart for zero. The hard part is the browser side: each fragment still needs its JavaScript to become interactive, and two fragments that need to talk now share one page with no shared runtime.

The simplest server-side split is one app per path: a proxy or CDN rule sends /flights to one app and /checkout to another. Moving between them is a full page load. Next.js calls this multi-zones. It gives full independence for a page load at each boundary, the trade-off the SPA vs MPA lesson discusses.

Browser at runtime: loading slices on demand

The shell loads in the browser, then fetches each slice's JavaScript from that team's own CDN path when it is needed. Teams deploy new files whenever they want, and the next page load picks them up. No one rebuilds the shell. This is what most people mean by micro-frontends, and it is where the next three chapters spend their time.

Decision Where should the travel site join its slices?

Build time (npm packages)

Each team publishes a package.

The shell installs and bundles all of them into one app.

Pros

  • Smallest, fastest bundle; one copy of every library.
  • Normal tooling, types, and tests across slices.

Cons

  • Every release goes through a shell rebuild.
  • Version bumps become a queue of pull requests.

Server or edge (HTML fragments, or one app per path)

A layout server, edge function, or proxy stitches fragments or routes paths to separate apps.

Pros

  • Fast first paint, good for SEO.
  • Fully independent deploys; per-fragment caching.

Cons

  • Interactive state across fragments is hard.
  • Per-path apps reload the page at each boundary.

Browser at runtime (Module Federation and friends)

The shell fetches each slice's code from its team's CDN when the route or fragment needs it.

Pros

  • Independent deploys with soft, single-page navigation.
  • Shared libraries can be loaded once.

Cons

  • More JavaScript and more request waterfalls.
  • Version drift and runtime-only failures.

What we pick: Start with the cheapest answer that removes the actual pain. If teams mostly own whole pages, one app per path behind a proxy gives independent deploys with almost no new technology. Reach for runtime composition only when several teams must share one page or one single-page navigation, and pair it with server rendering if first paint matters.

Check your understanding · Exercise 3

The checkout team publishes @travel/checkout@4.2.1 to npm, and the shell bundles it at build time. What has to happen before users get the fix? Select all that apply.

Module Federation, step by step

Module Federation lets one JavaScript build load modules from a different build at runtime, as if they were normal imports. It shipped in webpack 5 in 2020. Today it lives as its own project, Module Federation 2.0, with a shared runtime package and plugins for webpack, Rspack (which also has it built in), Vite, and others. It is the most common way to do runtime composition with React.

Hosts, remotes, and the remote entry

Two roles matter. A remote is a build that exposes some of its modules to others, and a host is a build that consumes them. The shell is a host; each team's slice is a remote. A build can be both.

When a remote builds, the plugin writes a small extra file, the remote entry (by convention remoteEntry.js), next to its normal chunks. It is a table of contents: "I am checkout, I expose ./App, and here are the chunk files that make it up." Module Federation 2.0 can also write a JSON manifest (mf-manifest.json) with the same information plus types and shared-library details, which tools and the runtime can read.

ts
// checkout team: rspack.config.ts
new ModuleFederationPlugin({
  name: "checkout",
  filename: "remoteEntry.js",
  exposes: { "./App": "./src/entry.tsx" },
  shared: {
    react: { singleton: true, requiredVersion: "^19.0.0" },
    "react-dom": { singleton: true, requiredVersion: "^19.0.0" },
    "react-dom/": { singleton: true, requiredVersion: "^19.0.0" }, // react-dom/client
  },
});

// shell: rspack.config.ts
new ModuleFederationPlugin({
  name: "shell",
  remotes: { checkout: "checkout@https://cdn.travel.example/checkout/remoteEntry.js" },
  shared: {
    react: { singleton: true },
    "react-dom": { singleton: true },
    "react-dom/": { singleton: true },
  },
});

// shell code: fetched from the checkout team's CDN on first use
const Checkout = lazy(() => import("checkout/App"));
The import checkout/App looks local, but the bundler turns it into a runtime fetch of the remote entry and then the chunks it lists. The react-dom/ key, with its trailing slash, also shares subpath imports such as react-dom/client; without it each build bundles its own copy.

What happens when a user opens checkout

Follow one navigation from the flights page to /checkout. The shell is already running, with React loaded.

  1. The shell's router sees /checkout and renders the lazy Checkout component, which triggers import("checkout/App").
  2. The federation runtime fetches remoteEntry.js from the checkout team's CDN (a few KB), unless it already did so at startup under the default share strategy described in the shared dependencies chapter.
  3. The runtime runs the shared scope negotiation: the remote says it needs React ^19.0.0; the shell already provided React 19.1. The ranges match, so the remote will use the shell's copy.
  4. The runtime fetches the chunks for ./App (say 120 KB of checkout code), but not React, because it is shared.
  5. The module's mount or default export runs, and checkout renders inside the shell's layout.
A sequence diagram between the shell, the federation runtime, and the checkout team's CDN. The shell imports checkout/App. The runtime fetches remoteEntry.js from the checkout CDN, which returns a 4 KB entry listing what it exposes and shares. The runtime checks the shared scope: checkout needs React ^19.0.0 and the shell offers 19.1, so checkout uses the shell's copy. The runtime fetches the App chunks, 120 KB with no React inside. Finally the runtime hands the module back to the shell, which mounts it.ShellMF runtimeCheckout CDNimport("checkout/App")GET remoteEntry.js4 KB: exposes, sharedreact ^19? use your 19.1GET App chunks120 KB, no Reactmodule ready: mount
Two network trips, and React is not one of them. The first trip is the small remoteEntry.js; the second carries only checkout's own code.

Now the key property. The checkout team deploys a new version at 14:00, and its CDN now serves a new remoteEntry.js that points at new chunk files. A user who opens the site at 14:05 gets the new checkout code, and the shell was never rebuilt.

To make this work, the remote entry must be served with a short cache lifetime (or no-cache with revalidation), while the chunks it lists have content hashes in their names and can be cached for a year.

Deploying a remote: upload, then switch the pointerStep 1 of 5: v1 is live

The checkout team's CDN with remoteEntry.js, cached for 60 seconds, pointing at either the v1 chunk app.a1f3.js or the v2 chunk app.b2c9.js, both cached for a year, and what the next page load gets. Step by step: v1 is live, the v2 chunk is uploaded, the entry is switched to v2, the next load gets v2, and a rollback switches the entry back to v1.checkout team's CDNremoteEntry.jsmax-age=60app.a1f3.jsv1, cached 1 yearnext loadgets v1the entry points at v1
The remote entry remoteEntry.js points at checkout's v1 chunk. The entry is cached for at most 60 seconds; the hashed chunk is cached for a year.

v1 is live: The remote entry remoteEntry.js points at checkout's v1 chunk. The entry is cached for at most 60 seconds; the hashed chunk is cached for a year.

What Module Federation 2.0 adds

The first version was wired into webpack's build: remotes were fixed URLs in the config. The 2.0 runtime (the @module-federation/enhanced and @module-federation/runtime packages) moves the logic into a small runtime you can call from code. The parts worth knowing:

  • Dynamic remotes. registerRemotes and loadRemote("checkout/App") let the shell read remote URLs from a config service, so a team can roll out to 5% of users, or roll back, without touching the shell.
  • Manifest. mf-manifest.json lists exposes and shared libraries, so the runtime can preload chunks in parallel.
  • Runtime plugins. Hooks around loading, to retry a failed remote, swap a URL, or report timings.
  • Type hints. A remote can publish its TypeScript types for the host to download.

Check your understanding · Exercise 4

The checkout team deployed at 14:00, but users still get the old checkout at 18:00. What is the most likely cause? Pick one.

single-spa, web components, and iframes

Module Federation answers "how do I load another team's code and share libraries?". It does not decide when a slice mounts, how it unmounts, or how isolated it is. Three other tools answer those questions, and they are often combined with federation rather than used instead of it.

single-spa: a lifecycle router

single-spa is a small framework whose only job is to mount and unmount whole applications as the URL changes. A root config registers each app with a name, a way to load it, and an "active when" rule such as /checkout. Each app exports three lifecycle functions: bootstrap (once), mount (each time it becomes active), and unmount (each time it stops being active).

ts
import { registerApplication, start } from "single-spa";

registerApplication({
  name: "@travel/checkout",
  app: () => import("@travel/checkout"), // resolved by an import map
  activeWhen: ["/checkout"],
  customProps: { authToken: () => session.token },
});

start();
The root config: when the path starts with /checkout, load and mount the checkout app; when it no longer does, unmount it.

single-spa classically loads apps through an import map: JSON in a script type="importmap" tag that maps a bare name like @travel/checkout to a URL. Changing that URL is the deploy. All modern browsers support import maps; older setups used the SystemJS loader instead.

Its strength is mixing frameworks: the root config does not care whether checkout is React and the legacy account area is Angular, as long as each exports the three lifecycles. Adapters like single-spa-react write those functions for you.

Web components: a framework-neutral wrapper

A slice can register a custom element, for example travel-checkout, and render its app inside it. The shell then only writes the tag into the page and passes data through attributes or properties. The browser calls connectedCallback when the element is added and disconnectedCallback when it is removed: those are the mount and unmount contract, built into the platform.

With shadow DOM, the slice's styles cannot leak out and the shell's cannot leak in, which ends the classic bug where one team's .button rule restyles another team's buttons. The costs: base styles must be passed in on purpose (CSS custom properties do cross the boundary), portals that append to document.body escape the styling, and server rendering needs declarative shadow DOM. The web components lesson goes deeper.

Iframes: the strongest walls

An iframe loads a separate document inside the page, with its own CSS, globals, and copy of every library, and an uncaught error stays inside it. Served from a different origin, its scripts can't read or change the host page at all, and the sandbox attribute can limit what it may do further. No other approach comes close on isolation.

Everything else costs more: its own copy of every library, manual resizing through postMessage, modals that can't escape its box, and extra work for the URL, the back button, focus, and screen readers. As the clickjacking and iframes lesson explains, every postMessage must name the target origin and check event.origin on receipt. Use iframes for parts you don't trust or don't control: a payment provider's card form, a third-party widget, or a legacy app you are slowly replacing.

A comparison matrix of six composition approaches against five properties: own deploy, shares libraries, isolation, simplicity, and soft navigation. npm packages: own deploy no, shares libs yes, isolation no, simplicity yes, soft nav yes. federation: own deploy yes, shares libs yes, isolation no, simplicity part, soft nav yes. single-spa: own deploy yes, shares libs part, isolation no, simplicity part, soft nav yes. web components: own deploy yes, shares libs part, isolation part, simplicity part, soft nav yes. iframes: own deploy yes, shares libs no, isolation yes, simplicity part, soft nav part. app per path: own deploy yes, shares libs no, isolation yes, simplicity yes, soft nav no.own deploysharingisolationsimplesoft navnpm packagesnoyesnoyesyesfederationyesyesnopartyessingle-spayespartnopartyesweb componentsyespartpartpartyesiframesyesnoyespartpartapp per pathyesnoyesyesno
No row is all green. Isolation and library sharing pull in opposite directions, and only build-time packages give up independent deploys.
ApproachSolvesDoes not solvePairs well with
Module FederationLoading remote code, sharing librariesLifecycle, CSS isolationA router in the shell, or single-spa
single-spaMount and unmount by route, mixed frameworksLibrary sharingImport maps or Module Federation
Web componentsA neutral mount contract, CSS isolationLoading code, sharing librariesModule Federation or import maps
IframesFull isolation and securitySize, focus, URL, shared librariespostMessage with origin checks

Check your understanding · Exercise 5

A bank must embed a partner's loan calculator, built and hosted by the partner, inside its logged-in dashboard. Which approach fits best? Pick one.

Routing composition and the app shell

There is one address bar and one back button, but several teams want to own URLs. Routing is where split apps most often feel broken: a back button that skips a step, a link that reloads the page, a 404 with the wrong layout. The fix is a clear rule about who owns which part of the URL.

Two levels: the shell picks, the slice routes

The common rule is two-level routing. The shell owns the first path segment and uses it only to pick a slice. The slice owns everything after it, with its own router, and can add or rename its inner routes without asking the shell team.

A routing tree. At the top, the shell router. Below it, three slices chosen by the first path segment: /flights/* for the flights slice, /checkout/* for the checkout slice, and /account/* for the account slice. Below a dashed line, each slice's own router: flights has /search and /:id, checkout has /cart, /payment, and /done, and account has /trips. The URL /checkout/payment is highlighted.shell router/flights/*flights slice/search/:id/checkout/*checkout slice/cart/payment/done/account/*account slice/tripsabove the dashed line: the shell; below it: each slice
For /checkout/payment, the shell matches only /checkout; the checkout router matches /payment. Below the dashed line, a team can add routes without telling the shell.

Take /checkout/payment?step=2. The shell reads /checkout, mounts the checkout slice, and passes basePath: "/checkout". Checkout's router matches /payment relative to that base path and reads ?step=2. Search and query parameters belong to the slice that is active.

With React Router, a slice that mounts its own React root creates its router with basename set to the base path. A slice rendered inside the shell's React tree, as in the shell code later in this chapter, must not create a second router: it renders nested Routes under the shell's /checkout/* route instead.

  • One history. All routers must use the same browser history (history.pushState and the popstate event). Two routers that each keep their own in-memory history will disagree about the back button.
  • Cross-slice links. A link from flights to /checkout/review is not a link inside flights. The slice should call a navigation function the shell provides, or dispatch a navigation event, so the shell can switch slices without a full reload.
  • Not found. If the first segment matches no slice, the shell shows the 404. If it matches but the inner path does not, the slice shows its own 404 inside the shell's layout.

The app shell and loading states

The app shell is also the minimal HTML, CSS, and JavaScript that draws the frame before any slice loads: small, cached for a long time, and server-rendered if possible. The content area then shows a skeleton that matches the slice's layout while its code arrives, so nothing jumps when it renders (a layout shift that hurts CLS, cumulative layout shift).

Each slice mount sits inside an error boundary with a useful fallback ("Checkout is unavailable, try again"), while the header and navigation keep working. Contained failure only holds if the shell actually catches the error.

tsx
// shell: one route per slice, each with its own boundary
const Flights = lazy(() => import("flights/App"));
const Checkout = lazy(() => import("checkout/App"));

<Routes>
  <Route path="/flights/*" element={<SliceSlot name="flights" C={Flights} />} />
  <Route path="/checkout/*" element={<SliceSlot name="checkout" C={Checkout} />} />
  <Route path="*" element={<NotFound />} />
</Routes>

function SliceSlot({ name, C }: { name: string; C: ComponentType<{ basePath: string }> }) {
  return (
    <ErrorBoundary fallback={<SliceDown name={name} />}>
      <Suspense fallback={<SliceSkeleton name={name} />}>
        <C basePath={`/${name}`} />
      </Suspense>
    </ErrorBoundary>
  );
}
The /* suffix hands the rest of the path to the slice's own router. The boundary and skeleton are per slice, so one failure stays in one box.

Soft or hard navigation between slices

With runtime composition, moving from flights to checkout is a soft navigation: the shell unmounts one slice and mounts another, and the header never flickers. With one app per path, it is a hard navigation: a full page load. Hard navigations are simpler and fully independent. Soft navigations feel more like one app, but every slice must clean up on unmount (timers, listeners, global CSS, open sockets), or memory grows with every visit.

Check your understanding · Exercise 6

Users report that after going from search to checkout, the back button skips the search results and goes to the home page. What could cause this? Select all that apply.

Shared dependencies and version drift

Five slices that each bundle their own React would ship React five times. So runtime composition shares the big libraries. Sharing saves bytes, but it quietly brings back the coupling the split was meant to remove: if everyone uses the same React, everyone must agree on which React.

The shared scope: a small market for libraries

In Module Federation, every build that lists a library under shared takes part in a shared scope: a map, kept by the runtime in the page, from library name to the versions on offer and who offered them. When a build initializes, it adds what it brought. When code asks for react, the runtime looks at the offers and the asker's requiredVersion, and picks one.

The shared scope as a table with one row per library. react: the shell offers 19.1.0 and checkout offers 19.0.2; as a singleton, everyone uses 19.1.0. react-dom: the same, everyone uses 19.1.0. The design system @travel/ui: the shell offers 3.6.0 and checkout offers 3.9.1; as a singleton the highest wins, so everyone uses 3.9.1. date-fns is not a singleton: flights offers 3.6.0 and checkout offers 2.30.0, whose range does not match, so there are two copies.libraryoffered by each buildresultreact19.1.0 shell19.0.2 checkoutall: 19.1.0react-dom19.1.0 shell19.0.2 checkoutall: 19.1.0@travel/ui3.6.0 shell3.9.1 checkoutall: 3.9.1date-fns3.6.0 flights2.30.0 checkouttwo copies
Singletons collapse to one version, normally the highest offered, even when that is newer than what the shell itself was built with. Non-singletons fall back to separate copies when ranges don't overlap.
  • requiredVersion: the semantic version (semver) range this build accepts, such as ^19.0.0. By default the plugin reads it from the build's package.json.
  • singleton: true: only one copy may exist in the page. The runtime picks one version (normally the highest one offered) and everyone gets it. If it falls outside someone's range, they get a console warning and the singleton anyway.
  • strictVersion: true: turns that warning into a load failure for the build whose range is not met. Safer than a silent mismatch, but it fails in production, at run time.

Without singleton, each build gets the highest offered version that fits its range, or falls back to its own bundled copy if none fits. That is fine for a pure utility like a date library (two copies cost bytes, nothing more). It is a bug for anything that keeps module-level state.

Which offers exist when the runtime chooses depends on the share strategy. Module Federation 2.0's default, version-first, loads every remote entry at startup so it can pick the highest version, at the cost of extra requests and a startup failure if a remote is down. The other option, loaded-first, fetches a remote entry only when its code is needed and reuses whatever copy is already loaded.

Why two Reacts break your app

Hooks only work when a component's copy of react is the same copy React DOM renders with: while rendering, React DOM puts the current hooks implementation into a module-level object inside that copy. If the shell renders with one copy and checkout's components call hooks from a second copy of react, that object is empty and the hooks throw the well-known "Invalid hook call" error.

Context is the quieter version of the same bug. If the design system is loaded twice, each copy creates its own ThemeContext, so a ThemeProvider from one copy is invisible to components from the other, and the theme silently falls back to its default.

The same is true for anything stateful: a router, a state library, a design system that uses context, an i18n instance. These must be singletons, or must not cross the boundary at all.

Loyalty requires react
Shared options
The shell offers React 19.1.0. The loyalty slice requires react ^18.2.0, with singleton on and strictVersion off. Result: 1 copy of React on the page. Console: warn: 19.1.0 of singleton react does not satisfy ^18.2.0.shelloffers react 19.1.0loyaltyneeds react ^18.2.0react 19.1.01 React on the pagewarn: 19.1.0 of singleton react does not satisfy ^18.2.0
The shell was built with React 19.1.0 and offers it in the shared scope. Change loyalty's range and the shared options to see what the runtime does.

A singleton never loads a second copy, so loyalty gets React 19.1.0 anyway and the runtime only warns. Code written for React 18 now runs on 19: fine if it used no removed APIs, a production bug if it did.

Version drift over time

Version drift is what happens when nobody owns the shared versions. In month one, every slice is on React 19.1 and design system 3.4. By month nine, checkout is on 3.9, flights is stuck on 3.2, and loyalty wants React's next major. The page shows two shades of the primary button, and the upgrade needs every team at once: the coordinated release the split was meant to avoid.

Drift cannot be avoided, only managed. Teams that run micro-frontends well treat shared versions as a platform contract:

  1. A platform team owns the shell and publishes the supported range for each shared library, for example React ^19.1.0 and design system ^3.6.0.
  2. CI in every slice checks its package.json against that list and fails the build if a slice falls out of range, so drift is caught at build time, not in production.
  3. Minor upgrades of shared libraries roll forward continuously with automated pull requests. Majors get a migration window (say, one quarter): each slice first makes its code work on both majors and widens its range, for example ^18.3.0 || ^19.0.0, and then the shell switches the one shared copy.
  4. Share as little as possible. React, React DOM, the router, and the design system usually. Not lodash, not a date library: let teams bundle those, or tree-shake them.

Check your understanding · Exercise 7

The shell offers React 19.1 as a singleton. The loyalty slice was built with requiredVersion: "^18.2.0" and no strictVersion. What happens when loyalty loads? Select all that apply.

Looking like one product: design, communication, and contracts

Users don't know about your teams. To them it is one site, and every seam they can see (a different button style, a wrong cart count in the header, a second login prompt) costs trust.

Design consistency

The design system is the main tool. As the design systems lesson explains, it holds the tokens (colors, spacing, type) and the components. In a split frontend it is usually shipped two ways at once: design tokens as CSS custom properties that the shell sets on the page root, so every slice reads the same --color-primary at run time, and components as a shared package, often a federated singleton, so a fix to the date picker reaches every slice without each one rebuilding.

CSS is the other source of seams: global styles from one slice restyle another, and the last to load wins. Scope every slice's CSS (CSS Modules or prefixed class names, @layer for a known order, or shadow DOM), and let only the shell style html and body.

How slices talk to each other

Slices do need to share some facts: the cart count in the header changes when checkout adds an item; the currency the user picked in the header changes prices in flights. Choose the weakest link that works, in this order:

  1. The URL. If the fact belongs in a link (a search query, a selected hotel, a currency), put it in the URL. Every slice can read it, it survives reload, and nothing else is shared.
  2. Props from the shell. Facts the shell already owns (user, locale, feature flags) are passed into mount and updated through a callback or a re-render.
  3. Browser events. For "something happened" messages, dispatch a CustomEvent on window, such as cart:changed with { count: 3 }. The sender doesn't know who listens. Name events with a team prefix and version their payloads.
  4. The server. If two slices need the same data, both fetch it, with a cache to avoid paying twice. The server stays the source of truth.
ts
// shared contract package: @travel/events (types only, no runtime state)
export type CartChanged = { v: 1; count: number; totalCents: number };

// checkout slice
window.dispatchEvent(
  new CustomEvent<CartChanged>("checkout:cart-changed", {
    detail: { v: 1, count: 3, totalCents: 41800 },
  }),
);

// shell header
window.addEventListener("checkout:cart-changed", (e) => {
  const { count } = (e as CustomEvent<CartChanged>).detail;
  setCartCount(count);
});
The shared package holds only types. Each side can deploy alone as long as version 1 of the payload keeps its shape.

Avoid a shared global store (one Redux store that every slice reads and writes). It feels convenient and it rebuilds the monolith in the worst place: every slice now depends on every other slice's state shape, a reducer change in one team breaks another team at run time, and nobody can deploy alone. The state categories lesson's advice holds here even more: keep server state on the server, UI state in the slice, and share only a few small facts.

Testing at the boundaries

Each slice is tested by its team as usual: unit and integration tests inside its repo. The new risk is the seams, which no single team's tests cover. Three kinds of checks catch most seam bugs:

  • Contract tests. The shell publishes the mount contract and event types as a package. Each slice's CI type-checks against it and runs a test that mounts the slice with sample props and unmounts it, checking that it leaves no listeners or DOM behind.
  • Integration smoke tests. After a slice deploys to staging, a short end-to-end suite loads it inside the real shell with the real shared libraries. This is the only test that catches a renamed exposed module or a shared-version mismatch.
  • Production guards. Error boundaries per slice, error reports tagged with the slice name and version, and a way to roll one slice back to its previous remote entry in seconds.

Ownership

The split only works if ownership is visible. Each slice has an owning team in a service catalog or CODEOWNERS file, its own dashboards for errors and load time, and its own on-call. The shell, the shared library list, and the design system are owned by a platform team whose job is to keep the contracts small, stable, and well documented.

Check your understanding · Exercise 8

The flights slice needs to show prices in the currency the user picked in the shell's header. Which designs keep the slices independent? Select all that apply.

The performance bill

Micro-frontends never make a page faster on their own. With care the split costs users little; without care it costs a lot, in three ways: duplicate code, request waterfalls, and extra work at each mount.

Duplicate code

React and React DOM 19 together are about 260 KB of minified JavaScript and about 65 KB after gzip. The compression lesson measured React DOM's client file alone at 242 KB minified and 61 KB gzipped; React itself adds a few KB more. Shared as a singleton, the page pays that once. If three slices each bundle their own copy because their versions drifted or nobody configured sharing, the page pays three times.

Add a design system, a router, and a data-fetching library per slice, and duplication easily reaches several hundred KB.

Use the rule of thumb from the estimation lesson: about 1 second of main-thread work per MB of unzipped JavaScript on a mid-range phone. 500 KB of duplicated libraries is about half a second of parsing and running that the user waits through, on every cold load, before counting download time.

A bar chart of unzipped JavaScript on one product page, to scale, with main-thread time at about 1 second per MB on a mid-range phone. One app: 500 KB of feature code plus 400 KB of libraries (React, React DOM, and the design system), 900 KB, about 0.9 seconds. Three slices sharing those libraries as singletons: the same plus about 100 KB of federation runtime and glue, 1,000 KB, about 1 second. Three slices sharing nothing: the same feature code and libraries plus 800 KB of duplicate libraries and 40 KB of glue, 1,740 KB, about 1.7 seconds.unzipped JS on one page, to scaleone appfeature codelibs900 KB, about 0.9 s3 slices, shared singletonsfeature codelibs1,000 KB, about 1.0 s3 slices, nothing sharedfeature codelibsduplicates1,740 KB, about 1.7 s
Done well, the split costs about 10% more script. Done without sharing, two extra copies of React, React DOM, and the design system add about 0.8 seconds on a mid-range phone. Sizes are illustrative.

Request waterfalls

Runtime composition adds steps that each wait for the one before: the HTML loads the shell; the shell's code runs and learns which slice it needs; it fetches that slice's remote entry; the entry names the chunks; the chunks load and run; only then does the slice ask its API for data. Each arrow is a round trip, often 50 to 150 ms on mobile, and a nested fragment repeats the chain inside the slice.

Two timelines of loading a federated checkout slice on mobile, from 0 to 1,200 milliseconds. Chained: HTML from 0 to 250 ms, shell JavaScript 250 to 450, the remote entry 450 to 570, the slice chunks 570 to 800, and the API data 800 to 1,050, so content appears at about 1,050 ms. Preloaded: HTML 0 to 250, and from 150 ms the shell JavaScript, the entry and chunks, and the API data all load in parallel, ending by 470 ms, so content appears at about 470 ms.chained: content at about 1,050 msHTMLshell JSremote entryslice chunksAPI datapreloaded: content at about 470 msHTMLshell JSentry + chunksAPI data0 ms400 ms800 ms1200 ms
Each step in the chain waits for the one before. Preload hints like modulepreload from the server, plus starting the data request early, turn the staircase into parallel bars. Times are illustrative.

The fixes all flatten the chain so requests run in parallel:

  • Preload what the route needs. The server knows the URL, so it can put link rel="modulepreload" or preload hints for the slice's entry and main chunks into the HTML. The Module Federation 2.0 manifest makes this practical because it lists the chunks.
  • Start data early. Let the server or shell start the slice's main API request in parallel with its code, or render the slice on the server so data and HTML arrive together.
  • Prefetch the next slice. When the user hovers a link to checkout, fetch checkout's remote entry and chunks, as the prefetching lesson describes.
  • Cache correctly. Short cache on remote entries, long immutable cache on hashed chunks, so repeat visits only revalidate a few KB.

Runtime cost after load

Each slice that mounts creates its own React root, providers, and data cache, and a slice that never fully unmounts keeps its memory after the user leaves. Measure per slice: tag real user monitoring with the active slice name, and give each slice its own performance budget.

In real products

  • Zalando described Project Mosaic around 2015 to 2016: server-side composition of fragments owned by different teams, with an open-source layout service called Tailor (now archived) that streams fragments into a page.
  • Module Federation 2.0 was released in 2024 by ByteDance's web infrastructure team, which also builds Rspack, together with Zack Jackson, who created the original Module Federation.

Check your understanding · Exercise 9

A product page mounts three slices. Each bundles its own React and React DOM (about 260 KB unzipped each) because sharing was never configured. Roughly how much main-thread time does the duplication cost on a mid-range phone? Pick one.

When not to, and what to do instead

Most teams that adopt micro-frontends would have been better off without them. That isn't a rule against them; it is a statement about who has the problem they solve. The problem is many teams blocking each other on one release. Small and medium teams don't have it, or have a cheaper fix for it.

Signs you should not split

  • Few frontend engineers. As a rough guide, under about 50 frontend engineers (five or six teams), the coordination cost of one app is usually smaller than the platform cost of many. This is a judgment call, not a law: some companies split at 30, and some run one app with 200 engineers.
  • Features cross every area. If most features touch search, hotels, and checkout together, every feature becomes a multi-team, multi-deploy project.
  • Performance is the top priority. A content site that lives and dies by Core Web Vitals pays the most for every extra KB and round trip.
  • No platform team. Without people to own the shell, shared versions, and contracts, drift wins within a year.
A decision tree. Do teams block each other on releases? If no, keep one app and fix builds and releases. If yes: are there more than about 50 frontend engineers? If no, use a modular monolith with monorepo tools. If yes: do several teams share one page? If no, use one app per path behind one domain. If yes, use runtime composition with Module Federation and shared singletons. Separately, for untrusted or third-party code, use an iframe.Teams block each other?nokeep one appfix builds and releasesyesOver ~50 FE engineers?nomodular monolithplus monorepo toolsyesTeams share one page?noone app per pathbehind one domainyesruntime compositionfederation, shared singletonsuntrusted or third-partycode: use an iframe
Every no leads to something cheaper and easier to undo. Runtime composition sits at the bottom because it is the answer only after the others don't fit.

Cheaper fixes for the same pain

Most of the benefits come from good boundaries and good tooling, not from runtime composition. Try these first, in order; each one is reversible, which a runtime split mostly isn't.

  1. A modular monolith. One app, but split into modules by business area with enforced boundaries (lint rules that forbid importing another module's internals) and code owners per folder. The modular frontend architecture lesson shows how.
  2. A monorepo with good tools. Nx or Turborepo build and test only what changed, so a 40-minute pipeline becomes 6 minutes for most changes. The monorepos lesson covers it.
  3. Faster, safer releases. Deploy many times a day from the main branch (trunk-based development), hide unfinished work behind feature flags, and roll back fast. Much of "we block each other" is really "we release rarely".
  4. Split by path. If teams own whole sections, one app per path behind a proxy gives independent deploys with almost no new runtime technology.

The lab below puts it together. Pick an approach and a number of teams, and watch what you get (deploy independence) against what you pay (duplicated bytes, consistency risk, and platform complexity).

Approach
Frontend teams
Module Federation with 15 frontend teams. Deploy independence 85%. Duplicated JavaScript on a busy page: 400 KB, about 400 ms on a mid-range phone. Consistency risk medium. Platform complexity medium.deploy independence85%duplicated JS400 KB, ~400 msconsistency riskmediumplatform complexitymedium
A rough model, not a measurement. Duplicated JS counts React, React DOM, and the design system (about 400 KB unzipped) for each extra copy on a page with up to three slices, at about 1 ms per KB on a mid-range phone.

Fits when several teams share pages. Needs a platform team and version rules, or duplication grows as versions drift.

Using it in a system design interview

Sometimes the interviewer asks directly ("how would 20 teams build this dashboard?"); more often you decide whether to bring micro-frontends up. Either way the signal is judgment: do you know the cost, and do you pick the cheapest option that fits?

  • Junior and mid-level. Know what a micro-frontend is, name two ways to compose (Module Federation, iframes), and know that sharing React matters. Say that it is an organization tool, not a speed tool.
  • Senior. Draw the shell and slices, name the four contracts (mount, route, events, shared code), explain the remote entry cache rule, and estimate the byte cost. Offer path-based splitting or a modular monolith as alternatives and pick one with reasons.
  • Staff. Talk about the organization: Conway's law, the platform team, how shared versions are governed, how drift is caught in CI, how a slice is rolled back, and how you would migrate a monolith one route at a time (the strangler fig pattern) without a big-bang rewrite.

Check your understanding · Exercise 10

A startup with 12 frontend engineers in two teams complains that builds are slow and releases collide. What should you suggest first? Select all that apply.

Summary

  • Micro-frontends solve an organization problem: many teams blocked by one release. They make the product bigger and more complex, never faster on their own.
  • Every split is a shell, some vertical slices, and four contracts: mount, route, events, and shared code. Keep the contracts small.
  • Compose at build time (packages), on the server or edge (fragments or one app per path), or in the browser at runtime (Module Federation, single-spa, web components, iframes). Pick the cheapest one that gives real independent deploys.
  • In Module Federation, the remote entry is the version pointer: short cache on it, long cache on hashed chunks.
  • Stateful libraries like React, the router, and the design system must be singletons. Shared versions are a platform contract, checked in CI, or drift wins.
  • Talk through the URL, props from the shell, and small typed events. Avoid a shared global store.
  • Measure the cost: duplicated KB at about 1 s per unzipped MB on a mid-range phone, plus round trips in the load chain. Preload and share to flatten them.
  • Under roughly 50 frontend engineers, a modular monolith or monorepo with fast releases usually beats a runtime split.

In the interview

Questions interviewers ask about this. Answer each out loud first, then compare.

Question 1

Your company has one React app and 15 frontend teams who block each other's releases. Would you move to micro-frontends? How?

Question 2

How does Module Federation load a remote, and how do you make sure users get the new version right after a team deploys?

Question 3

Two micro-frontends on one page both use React. What can go wrong and how do you configure it?

Question 4

When would you use iframes instead of Module Federation?

Question 5

How do separate micro-frontends share state, such as a cart count in the header?

Practice it

These design problems lean on micro-frontends.

References

Read next