Micro frontends: one page, many teams
A lot of the screens you use every day are not one app. They are four or five, built by different teams, deployed on different afternoons, stitched into a single page in your browser somewhere between the CDN and the paint. Micro frontends are what happens when the frontend copies the backend's microservice trick — and like microservices, they solve an organisational problem and hand you a technical bill. This is the whole picture: the four ways to stitch, what independent deploys actually buy, what they cost in kilobytes, and the one honest test for whether you need any of it.
The issue
It is 5:40pm on a Friday. The checkout team finished a fix two days ago — a bug where the coupon field cleared itself on mobile — and it is still not live. It is not live because everything at this company ships together, and today the search team's build is red over a snapshot test nobody understands. So checkout waits. Search waits. The pricing experiment that marketing has been asking about since Tuesday waits. Four teams, one pipeline, and the slowest one sets the pace for all of them.
That specific Friday is the reason micro frontends exist. Not performance, not React, not modularity — that Friday. Somebody eventually says out loud: why does my finished work depend on your broken test? And the answer, once you follow it all the way down, is a piece of architecture. This issue is that architecture, in full: what it actually is, the four ways to build it, everything it breaks on the way, and — because this is the part that gets skipped — how to tell whether your company is the one it was designed for.
What a micro frontend actually is
A micro frontend is a piece of a user interface that one team owns from the database to the pixel, builds on its own, and deploys on its own schedule, which is then composed with other such pieces into one page that the user experiences as a single product. That is the whole definition, and the load-bearing words are deploys on its own schedule. Not "small". Not "a separate folder". Not "a component library". The test is brutally simple: if your team can push to production at 2pm on a Tuesday without asking anyone or waiting for anyone, you have a micro frontend. If you cannot, you have a folder.
It is worth being precise about what this is not, because three very common things get called micro frontends and none of them are. A shared component library published to npm is not one: when the design system releases a new button, every app must bump the version and rebuild, so nothing deployed independently. Lazy-loading a route with React.lazy is not one: the chunk splits at build time, but it is still one build, one artefact, one deploy. And a monorepo with four folders is not one either — the folders are a code-organisation choice; if CI builds and ships them as a unit, the Friday problem is completely untouched.
The obvious comparison is microservices, and it is a fair one — the pattern is deliberately the same idea moved up the stack. But there is one difference that causes most of the pain, and it is worth stating early. Microservices run on your servers, where you control the CPU, the memory, and the network, and where two services duplicating a JSON library costs you nothing anyone notices. Micro frontends run on the user's phone, on a shared network connection, in one JavaScript main thread, painting into one DOM. Everything you duplicate, the user downloads. Every boundary you draw, the browser has to be talked into respecting. The backend can afford this pattern much more comfortably than the frontend can.
It is an org-chart problem first
Conway's law says an organisation ships software shaped like its own communication structure. Micro frontends are that observation used on purpose: draw the software boundaries where the team boundaries already are, so that the two stop fighting. Which means the decision belongs to whoever owns the org chart at least as much as it belongs to the principal engineer — and it is genuinely, unromantically about coordination cost.
Here is the arithmetic in the least technical terms available. With one deployable, every release is a negotiation involving every team: a shared release branch, a shared QA window, a shared rollback. That coordination cost grows roughly with the square of the number of teams, because it is pairs of teams that have to agree. Two teams have one relationship to manage; eight teams have twenty-eight. At some point along that curve, the meeting about the release costs more than the release. Splitting the deployable is how you cut the curve back to a line — each team's release now involves one team.
And that is also exactly why it is the wrong answer for most companies. If you are a startup with six engineers, you have zero coordination cost to eliminate and you will trade it for four build pipelines, a shared-dependency policy, a shell nobody owns, and version skew bugs that only appear in production. You will have paid a large architectural bill to solve a problem you do not have. The pattern earns its cost somewhere north of half a dozen genuinely independent teams — and the useful signal is not headcount, it is how often finished work sits waiting on somebody else's pipeline.
Where to cut: routes or regions
Assume you are past the threshold. The first real design decision is where the seams go, and there are two shapes. A vertical split gives one team an entire route: search owns everything under /search, checkout owns everything under /cart and /checkout. A horizontal split puts several teams on one screen: on a product page, the platform team owns the header, the product team owns the gallery and description, the pricing team owns the price block, and the recommendations team owns the carousel underneath.
The difference in difficulty is not small. A vertical split can be enforced by a reverse proxy and needs almost no runtime machinery: a URL either belongs to you or it does not. A horizontal split means several independently built applications share one DOM, one CSS cascade, one scroll position, one focus ring, and one main thread, and they must lay out next to each other without any of them knowing the others' heights. Nearly everything in the rest of this issue — shared dependencies, style isolation, communication, error boundaries — is a horizontal-split problem. So the practical advice is: go vertical wherever the product lets you, and reserve horizontal for the handful of screens where the business genuinely has several owners.
One rule about the seams themselves, and it is the one most often broken: cut along what the business calls things, not along what engineers call things. Search, product, checkout, account, seller tools — these are domains, each with its own data, its own metrics, and its own product manager, so a team can own one and actually be autonomous. A "forms team", a "tables team", or a "CSS team" recreates the coordination problem in a new costume: every feature now needs three of them, and you have added a deployment boundary in between.
The four ways to stitch
Now the mechanics. Something has to take separately built applications and turn them into one page. There are four families of answers, and the honest way to order them is by when composition happens — because the later it happens, the more independent the deploy, and the more the browser pays.
Build-time composition is the one people try first: each team publishes an npm package, the container app installs them and builds one bundle. It is easy, it is fast for the user, and it fails the only test that matters — shipping a change means the container rebuilds and redeploys, so you are back to the release train with extra steps. It is worth listing only because so many teams end up here and believe they have arrived.
// container/package.json — every team is a dependency
{
"dependencies": {
"@shop/search": "^4.2.0",
"@shop/product": "^2.9.1",
"@shop/checkout": "^7.0.3"
}
}
// Checkout fixes a bug → publishes 7.0.4 → opens a PR on the container
// → container rebuilds → container redeploys. Three teams touched.
// The user waited two days for a one-line fix. This is the release
// train with extra ceremony.Route / proxy composition is the opposite extreme and criminally underrated. Each team deploys a complete, ordinary application, and a reverse proxy at the edge decides which one answers a given URL. No shared runtime, no shared bundle, no clever anything: /search is a different app in every sense of the word. The cost is that moving between two teams' routes is a full page load, so you lose in-memory state and pay a fresh startup. For a lot of products — admin panels, dashboards, marketplaces where users spend minutes in one section — that cost is imperceptible, and you have bought total independence for the price of an nginx config. Next.js multi-zones is the same idea with the framework's blessing.
# Four separate deployments. Four separate teams. One hostname.
# The user sees shop.example; the browser never learns otherwise.
location /search/ { proxy_pass https://search-app.internal/; }
location /p/ { proxy_pass https://product-app.internal/; }
location /cart/ { proxy_pass https://checkout-app.internal/; }
location /orders/ { proxy_pass https://account-app.internal/; }
# Everything not claimed above still belongs to the old monolith.
# That last line is also your migration plan — see the strangler
# section below.
location / { proxy_pass https://legacy-monolith.internal/; }Iframes are the strongest isolation the web has: a separate document, a separate CSS cascade, a separate JavaScript heap, a separate crash. Two teams can use different frameworks, different React versions, even different decades of tooling, and genuinely cannot break each other. Spotify's desktop client famously ran on iframe-composed views for years before moving on from the approach. The reason it is not the default is UX: sizing is manual and fights responsive layout, modals cannot escape the frame's rectangle, deep links and the back button need bespoke plumbing, focus and accessibility get awkward, and every frame is a full page load. Worth reading the iframe issue for the security model, which is the part that makes iframes genuinely irreplaceable for embedding code you do not trust.
Web components are the platform's own answer: each team ships a JavaScript file that registers a custom element, and the container writes that element into its HTML like any other tag. The browser handles the lifecycle; shadow DOM handles the style isolation if you want it. It is framework-agnostic, standards-based, and it survives your framework's next major version — which is a real argument when the piece has to outlive whatever everyone is excited about this year.
// ── recommendations team ships this one file ──────────────────
class ProductRecos extends HTMLElement {
static observedAttributes = ["product-id"];
connectedCallback() { this.render(); }
attributeChangedCallback() { this.render(); }
async render() {
const id = this.getAttribute("product-id");
if (!id) return;
const items = await fetch("/api/recos/" + id).then((r) => r.json());
// Shadow DOM: this team's CSS cannot leak out, and the host
// page's CSS cannot leak in. That cuts both ways — see the
// styling section.
const root = this.shadowRoot ?? this.attachShadow({ mode: "open" });
root.innerHTML = items.map((i) => "<li>" + i.title + "</li>").join("");
}
}
customElements.define("product-recos", ProductRecos);
// ── the product page, owned by a different team ───────────────
// <script src="https://cdn.shop.example/recos/v31/element.js" async></script>
// <product-recos product-id="884213"></product-recos>
//
// The recos team ships by uploading a new file. The product team
// changes nothing. That is the whole point.Run-time module loading is where most serious horizontal-split systems land today, and Webpack's Module Federation is the best-known implementation (Vite and Rspack have compatible plugins). The mechanism: each remote build emits a small remoteEntry.js manifest describing what it exposes and what it needs. The host fetches that file at run time and imports modules across the network as if they were local. Nothing about the remote is baked into the host's build — which is exactly why the remote team can deploy alone.
// ── remote: the checkout team's build ─────────────────────────
new ModuleFederationPlugin({
name: "checkout",
filename: "remoteEntry.js", // the manifest the host fetches
exposes: {
"./CartWidget": "./src/CartWidget",
},
shared: {
// singleton: never load a second copy of React into this page.
react: { singleton: true, requiredVersion: "^18.2.0" },
"react-dom": { singleton: true, requiredVersion: "^18.2.0" },
},
});
// ── host: the shell's build ───────────────────────────────────
new ModuleFederationPlugin({
name: "shell",
remotes: {
// Note what is NOT here: any version of the checkout code.
// Only a URL. Checkout deploys by replacing what sits at it.
checkout: "checkout@https://cdn.shop.example/checkout/remoteEntry.js",
},
shared: {
react: { singleton: true, requiredVersion: "^18.2.0" },
"react-dom": { singleton: true, requiredVersion: "^18.2.0" },
},
});
// ── host: using it, at run time ───────────────────────────────
const CartWidget = React.lazy(() => import("checkout/CartWidget"));Two cousins worth knowing. single-spa is a router-and-lifecycle framework that predates Module Federation and solves the orchestration half of the problem — each application exports bootstrap, mount and unmount, and single-spa decides which are active for the current URL. And import maps are the browser-native version of the same pointer indirection: a JSON block in the HTML that maps a bare module name to a URL, which means changing which version a user gets is an edit to one file, no bundler involved.
One more family that deserves more attention than it gets: server-side composition. Each team serves an HTML fragment; a layout service assembles them into one document before it reaches the browser. Zalando's Tailor and Mosaic did this publicly, edge-side includes have done it for decades, and modern streaming SSR is the same instinct. The user gets one document with no client-side stitching waterfall, which is why this is usually the right answer for anything public and SEO-sensitive — and the reason it is not more common is that it needs real server infrastructure, which a static-hosted SPA does not.
The shell: the part nobody budgets for
Every runtime-composed system has a container — the shell, the host, the root config, names vary. It is the page the user actually loads, and it is a real product with real work in it: it owns the top-level routing, the persistent layout (header, nav, footer), the session and auth token, the shared dependency versions, the error boundaries around each fragment, the telemetry, and the manifest that says which version of each remote to load. Teams routinely forget to staff it and then wonder why nobody has upgraded React in two years.
That manifest is the quiet hero of the whole pattern. Once the shell resolves remotes from a JSON file rather than from its own build, you get deployment properties that are hard to buy otherwise: rollback is a pointer change measured in seconds rather than a rebuild measured in minutes; canary releases are a manifest that serves v43 to 5% of sessions and v42 to the rest; and a broken remote can be pinned back without a single other team noticing.
// The shell knows nothing about a fragment except three functions
// and a DOM node to put it in. Framework-agnostic on purpose: the
// account team can be on Vue and the shell never finds out.
export async function bootstrap() {
// Called once, ever. Expensive one-time setup goes here.
}
export async function mount(props) {
// props is the ONLY channel from shell to fragment:
// { domElement, basePath, user, locale, onEvent }
root = createRoot(props.domElement);
root.render(<CheckoutApp basePath={props.basePath} user={props.user} />);
}
export async function unmount() {
// Non-negotiable. Tear down listeners, timers, subscriptions,
// and portals. A fragment that leaks on unmount is the single
// most common bug in these systems — the user navigates away
// five times and the tab is now holding five live pollers.
root.unmount();
}And the failure mode to watch for: the shell grows. Somebody needs a shared modal, so it goes in the shell. Somebody needs a global toast, the shell. A shared cart badge, a shared analytics wrapper, a shared date formatter — the shell. Two years later the shell is a monolith with four satellites attached, every team is blocked on shell releases, and you have arrived back at the Friday you started from, having paid for the trip. The rule that holds: the shell owns composition, never features. If it renders a business concept, it is in the wrong place.
How the pieces talk
Two fragments on the same screen inevitably need to know about each other. The product page needs to tell the cart badge that something was added. The search filters need to tell the results grid what changed. Every team's first instinct is a shared store — one Redux instance, or an object on window — and it is the single most reliable way to destroy everything you bought. A shared mutable store is shared state, shared state means a shared release, and now checkout cannot change the shape of its own cart object without breaking three teams. You rebuilt the monolith, but distributed, which is strictly the worst of both.
The channels that actually work are deliberately weak. First, the URL: it is shared state the browser already manages, it survives reload, it is deep-linkable, and it is a contract everybody can read. If search filters live in the query string, the results grid does not need to know search exists. Second, named custom events: a fragment announces something happened and does not care who listens. Third, props and callbacks from the shell down: the shell knows the user and the locale, and passes them in. What connects all three is that no fragment ever reaches into another's internals.
// ✗ WRONG — a shared mutable global.
// Checkout renames "qty" to "quantity" in a routine refactor and
// the header silently renders NaN in production. No compiler, no
// test, no review caught it: the two lines live in two repos.
window.__APP_STATE__.cart.items.push({ sku, qty });
document.querySelector("#cart-count").textContent =
window.__APP_STATE__.cart.items.length;
// ✓ RIGHT — a named event with a versioned payload.
// product fragment: announce, then forget.
window.dispatchEvent(
new CustomEvent("shop:cart-changed", {
detail: { v: 1, itemCount: 3, subtotalPaise: 249900 },
}),
);
// header fragment: listen, validate, ignore anything unfamiliar.
window.addEventListener("shop:cart-changed", (e) => {
if (e.detail?.v !== 1) return; // future version: not mine
setCount(Number(e.detail.itemCount) || 0);
});
// ✓ ALSO RIGHT — the URL as the shared state nobody owns.
// search writes it; the grid reads it; neither imports the other.
history.pushState({}, "", "/search?q=headphones&max=2000&sort=rating");Shared dependencies, or: four copies of React
Here is the tax nobody quotes in the design doc. Four independently built React applications on one page means four copies of React and ReactDOM in the download unless you do something about it — call it 45 KB gzipped each, plus the router, plus the design system, plus whatever date library each team picked. It is entirely normal for a naive setup to ship 300 KB of framework where a monolith shipped 75 KB, and on a mid-range Android phone on a patchy connection that is not an abstraction, it is seconds.
The fix is dependency sharing — singleton: true in the Module Federation config, or an import map, or a plain <script> with the framework on window if you are being unfashionable and effective. The first fragment to load provides React; everyone else uses it. But read what you just agreed to: React is now a shared contract across four repos. When the platform team wants React 19, every fragment must be compatible at the same time — which is a coordinated release, which is the exact thing you built all of this to avoid.
There is no clean escape, only an honest policy. The one that works in practice: share the small set of things that genuinely must be singletons — the framework, the router, the design system — treat that set as a versioned platform contract with a published upgrade window ("React 19 lands in Q3, everyone on a compatible version by then"), and let teams own everything else outright, duplication and all. Nobody should be coordinating on which charting library they use. A related trap worth naming: some libraries break in interesting ways with two copies on the page even when the bytes are affordable — anything using React context, or a state library holding a module-level singleton, will quietly fail to see the other copy's provider.
CSS, or: the global namespace comes back
Inside one build, CSS Modules or a CSS-in-JS library keeps class names unique and you stop thinking about collisions. Put two independent builds on one page and the global namespace returns exactly as it was in 2011: two teams both defined .btn-primary, the second stylesheet to load wins, and checkout's button is suddenly the wrong colour because search shipped a redesign. Worse, the bug is load-order dependent — it will not reproduce locally, where you only ran one app.
/* search/styles.css */ .btn-primary { background: #2563eb; }
/* checkout/styles.css */ .btn-primary { background: #16a34a; }
/* → whichever loads second wins, and load order is a race. */
/* FIX 1 — hashed class names. Build-time, zero discipline needed,
the right default. Every team's bundler emits unique names. */
.btn-primary_a91f3 { background: #2563eb; }
/* FIX 2 — an enforced per-team prefix. Ugly but readable in
devtools, and it makes ownership obvious at a glance.
Enforce with stylelint in CI, not with a wiki page. */
.chk-btn-primary { background: #16a34a; }
/* FIX 3 — shadow DOM. Total isolation, and the trade is total:
your global design-system stylesheet cannot get in either, so
you ship tokens via CSS custom properties, which DO pierce it. */
:host { --brand: #16a34a; }
.btn-primary { background: var(--brand); }Three more style problems that only exist here. Design drift: four teams shipping independently means four slightly different button radii within about six months, and the only real defence is a design system consumed as a versioned package with tokens rather than a Figma file everybody promises to check. Layout ownership: a fragment must never set its own width or margins — the container owns the box, the fragment owns what is inside it, or two teams will fight over the same 16 pixels forever. And z-index: modals, dropdowns and toasts from different teams stack in whatever order the DOM happened to end up in, so the shell should own the stacking scale and expose a portal target rather than letting everyone pick 9999.
Routing, deep links, and the back button
Two routers on one page will fight over the history API, and the symptom is always the same: the back button does something absurd, or a deep link renders the right fragment showing the wrong screen. The arrangement that works is strictly hierarchical. The shell owns the URL and decides which fragment is active for a given prefix; it passes the fragment its basePath; the fragment routes only inside that subtree and never reads or writes anything above it.
// ── shell ──────────────────────────────────────────────────────
// The shell matches prefixes only. It never looks at what comes
// after — that is somebody else's business, literally.
const ROUTES = [
{ prefix: "/search", remote: "search/App" },
{ prefix: "/orders", remote: "account/App" },
{ prefix: "/cart", remote: "checkout/App" },
];
mount(remote, { domElement: slot, basePath: match.prefix, user });
// ── fragment (account team) ────────────────────────────────────
// Scoped to its own subtree. /orders/884213 works as a deep link
// because the shell resolved /orders and handed the rest over.
<BrowserRouter basename={props.basePath}>
<Routes>
<Route path="/" element={<OrderList />} />
<Route path="/:id" element={<OrderDetail />} />
</Routes>
</BrowserRouter>
// The rule: a fragment that calls history.pushState("/checkout")
// is reaching into another team's route space. Ask the shell to
// navigate instead — props.navigate("/checkout") — so exactly one
// piece of code is ever in charge of the address bar.Failure isolation is the feature you actually want
This is the benefit that gets undersold, and for a commerce product it may be worth more than the deployment story. In a monolith, an uncaught exception in the recommendations carousel unmounts the React tree and the user is looking at a white page — on the screen where they were about to spend money. With fragments, the carousel is a separate module behind a separate boundary: it can fail, and Add to Cart keeps working. The user sees a slightly emptier page instead of a broken one, and revenue does not depend on the least-critical team on the page.
You do not get this automatically — it is a thing you build, and it is maybe forty lines. Every fragment gets an error boundary, a load timeout, and a defined fallback, and the fallback is a product decision: a skeleton for a carousel, a hard failure page for checkout. Which fragments are allowed to fail quietly is a conversation to have with a product manager, not an engineer.
function Fragment({ name, load, critical = false, fallback = null }) {
// A remote lives on a network the user is on. Treat loading it
// like any other network call: it can be slow, and it can be a
// 404 because somebody's CDN purge went sideways.
const Remote = React.lazy(() =>
Promise.race([
load(),
new Promise((_, reject) =>
setTimeout(() => reject(new Error("timeout")), 4000),
),
]),
);
return (
<ErrorBoundary
onError={(err) => track("fragment_failed", { name, err: err.message })}
fallback={critical ? <CheckoutUnavailable /> : fallback}
>
<React.Suspense fallback={<Skeleton />}>
<Remote />
</React.Suspense>
</ErrorBoundary>
);
}
<Fragment name="recos" load={() => import("recos/Carousel")} />
<Fragment name="checkout" load={() => import("checkout/App")} critical />Performance: the bill arrives at first paint
Beyond duplicated frameworks there is a structural cost: a waterfall. The browser loads the shell, the shell parses and fetches the manifest, the manifest names a remoteEntry.js, that names the real chunks, and only then does anything render. That is three or four sequential round trips before first paint, where a monolith had one — and on a 4G connection with 150ms of latency you have spent half a second doing nothing but asking questions. Fragments arriving at different times also produce layout shift, which users experience as the page jumping under their thumb.
The things nobody warns you about
The architecture diagram is the easy part. What actually decides whether teams stay happy is the day-to-day, and there are five places it gets rough. Local development: working on the header now means running the shell plus three fragments, and the usual fix is a dev shell that loads production remotes for everything except the one you are editing. Testing: unit tests are unaffected, but end-to-end tests now run against a combination of versions that may have never existed before and will never exist again, so you need contract tests on the event payloads and a staging manifest that pins known-good versions.
Observability: a stack trace from production is useless unless it says which fragment and which version produced it, so every fragment tags its errors and its telemetry with a name and a build hash, and source maps get uploaded per fragment. Auth: four fragments each refreshing an expiring token independently is four race conditions, so the shell owns the session and hands down a token or, better, a getToken() function that dedupes refreshes. And accessibility: four teams independently produce four <h1> elements, competing focus traps, and duplicate landmark regions on one page — which is invisible to everyone until somebody uses a screen reader, and is the most common quiet failure of the whole pattern.
Migrating without a rewrite
Almost nobody starts here — you arrive with a large existing frontend and a mandate to unblock teams. The pattern is the strangler fig: put a proxy in front of the monolith, route one narrow slice to a new independently deployed app, and repeat until the monolith is small enough to be boring or gone. It works because at every point in the migration you have a shipping product, and you can stop whenever the remaining pain stops justifying the work — which is a property no rewrite has ever had.
The order matters. Take a slice that is genuinely low-traffic and self-contained first — order history, account settings, a seller dashboard — because the first slice is where you discover that your auth token does not survive the boundary and your design system assumes a global stylesheet. Do not start with checkout. Then take a slice with a real, loud team-autonomy problem, so the second migration produces a visible win and the funding continues. And keep the monolith as the default route the entire time: anything not explicitly claimed still goes to the old app, so a missed URL degrades to "the old page" instead of a 404.
The honest decision
So: the test. Not "is our codebase big" — big codebases are fine, and a monolith with clear module boundaries beats four badly bounded fragments every time. The test is how much finished work is sitting still, waiting on somebody else's pipeline. Go and count it for last month: features that were done and unshipped, hotfixes that waited on an unrelated red build, releases postponed because two teams could not agree on a window. If that number is small, micro frontends will cost you a great deal and return nothing. If teams are visibly blocked most weeks, you have found the problem this pattern was built for.
And before committing, exhaust the cheap options, because they buy most of the autonomy for a fraction of the cost: a monorepo with enforced code owners; trunk-based development with feature flags, so shipping code and releasing a feature stop being the same event; and independent deploy pipelines per package where the dependency graph already allows it. That combination takes about a week and no runtime complexity at all. Micro frontends take a quarter and never stop asking for maintenance.
One way to summarise the whole issue: micro frontends do not make your frontend better, they make your organisation faster, and they charge the user's browser for the privilege. That is a completely reasonable trade at a certain size and a completely unreasonable one below it, and the size is measured in teams, not in lines of code. If you are considering this because your codebase feels messy, the answer is module boundaries. If you are considering it because eleven teams share one Friday release train, the answer might genuinely be this — and the next thing to read is the iframe issue, because the strongest boundary on the list is also the oldest one the web has.