SevyDevy

Guide · Frontend

Micro-Frontends: When Splitting the Frontend Helps Teams

25 min readUpdated By Saket Bhatnagar

Researched and drafted with AI, reviewed and edited by Saket Bhatnagar.

In short

  • Micro frontends split one web app into parts that separate teams build, test and release on their own. The frontend is the part that runs in the browser, and users still see one app.
  • A small host app, the app shell, draws the page frame, signs the user in and picks which part to show. It loads each team's part while the page runs, so no shared rebuild is needed.
  • Parts join as Web Components (HTML tags a team defines itself), as plain scripts, as iframes (pages inside a page) or through Module Federation. That is a feature of the webpack build tool that lets separate builds share code.
  • The price is real: more release pipelines (automatic build, test and deploy steps), version clashes, harder debugging and bigger downloads. One extra copy of React and React DOM 19.3.1, the libraries many teams use to build and draw screens, measured 68 KB gzipped (compressed) in our build. For one or two teams, one tidy app is usually better.
  • Beginners can stop after the section on when to use micro frontends. Engineers then get webpack 5.112.0 test results on which React copy each part runs and whether it renders, a tested Web Component and checks with expected output.

What goes wrong when many teams share one frontend?

Teams start waiting for each other. Everyone shares one codebase, one build and one release, so a failing test in one team's corner holds back every other team's finished work.

Picture a common setup. A super-app lets people book trains and flights, pay electricity and phone bills, and buy insurance on one website. The setup is made up, but the problem is real.

Three teams build it: travel, bill-pay and insurance. They share one React codebase (React is a popular library for building screens). About 45 developers commit to it every week.

The whole app is built and released together every Thursday. One build takes about 25 minutes, and every team's tests must pass before anything ships.

One Monday, the travel team fixes a fare display bug before a festive sale. On Thursday a failing bill-pay test blocks the release, so the fix waits a week. In one quarter, 9 of 13 releases slip this way (made up).

The engineering manager asks the frontend platform lead one question. Can each team ship its own part without waiting for the others? The honest answer is yes, with micro frontends, but only if the company accepts new costs.

What are micro frontends?

Micro frontends are an architecture style where one web app is split into smaller frontend apps that separate teams build and deploy on their own. A shared shell joins them into one product.

Cam Jackson's 2019 article on martinfowler.com defines it as independently deliverable frontend applications composed into a greater whole. The key word is deliverable: each part can go live without the others.

Here is an everyday picture. Think of a big trade fair, such as the one at Pragati Maidan in Delhi. Each state builds its own pavilion with its own team, design and opening date.

The fair authority still sets common rules: one entry gate, one site map, fire safety and plot sizes. Visitors walk through one fair, not twenty separate events.

How the trade fair maps to micro frontends
At the fairIn the web app
A state's pavilionOne team's micro frontend, such as travel
The state team that builds itThe product team that owns that business area
Entry gate and site mapThe app shell: frame, login and routing
Fair authority and its rulesThe platform team, the contracts and the size budgets
Opening date chosen by each stateEach team's own release pipeline

Where the picture breaks: pavilions stand apart, but parts on a web page share one browser tab. They share its memory, its global styles and often one copy of React. A bad script can blank the whole page unless the shell catches it.

You need four words to follow the rest of this guide.

  • App shell. The small host app that loads first. It draws the header and bottom menu, signs the user in, and decides which part to show for each address.
  • Micro frontend. One team's part of the app, with its own code, tests and release. The super-app has three: travel, bill-pay and insurance.
  • Composition. Joining the parts into one page. It can happen when you build, on the server, or in the browser while the page runs.
  • Contract. The small, agreed interface between parts: the tag name to show, the attributes it takes and the events it sends. Teams change anything inside a part, never the contract without notice.

How do micro frontends fit together on one page?

They fit together through composition: the shell loads each team's bundle and gives it a slot on the page. A bundle is the packed JavaScript and CSS (styling rules) that a build produces.

Here is the design the platform lead draws. Start at the user's phone. Notice that each team's bundle sits on the CDN (a network of servers that delivers files from near the user).

The super-app with micro frontends

Notice that the shell reads only a small manifest of addresses. Each team uploads its bundle to the CDN on its own schedule.

Diagram connections:

  • User's phone browser to App shell (Loads first)
  • App shell to Release manifest (Reads addresses)
  • App shell to CDN (Fetches bundles)
  • CDN to Travel micro frontend
  • CDN to Bill-pay micro frontend
  • CDN to Insurance micro frontend
  • Travel micro frontend to Shared design system (Uses)
  • Bill-pay micro frontend to Shared design system (Uses)
  • Insurance micro frontend to Shared design system (Uses)
  • User's phone browser: Opens the super-app website
  • App shell: Header, login, routing. Owned by platform team
  • Release manifest: Current bundle address for each part
  • CDN: Serves every team's bundle files
  • Travel micro frontend: Travel team, trains and flights
  • Bill-pay micro frontend: Bill-pay team, electricity and phone bills
  • Insurance micro frontend: Insurance team, policies and claims
  • Shared design system: Buttons, colours and fonts all parts use

The release manifest is a small JSON file (a plain text data format) that maps each part to its current bundle address. When the travel team releases, its pipeline uploads a new bundle and changes one line in the manifest. Nobody else rebuilds anything.

Jackson's article describes five ways to compose the parts. Each one moves the join to a different moment.

  • Build-time packages. Each part is published as a package that the shell installs. Every change needs a new shell build, so teams release in lockstep (all together) again.
  • Server-side composition. A server stitches HTML pieces from each team into one page before sending it.
  • Run-time JavaScript. The shell loads each team's script in the browser and calls a mount function that the script exposes. It suits single-page apps, which redraw screens without loading new pages.
  • Web Components. Each team registers its own HTML tag, and the shell places that tag on the page. The tag is the contract, and it is a browser standard.
  • Iframes. Each part runs inside a frame, a page within the page. Isolation is strong, but sizing, deep links (links to an inner screen), focus and shared login get awkward.

Jackson recommends strongly against the build-time route, because of the lockstep releases. The article calls run-time JavaScript its default choice and the one its author has seen in real projects most often.

Ways to compose micro frontends, as the super-app's platform team rated them
ApproachIndependent releaseIsolationMain painGood fit
Build-time packagesNo, shell must rebuildLowLockstep releasesShared UI libraries, not whole parts
Server-side compositionYesMediumInteractive pages need extra workContent-heavy pages
Run-time JavaScriptYesLow by defaultGlobal CSS and library clashesSingle-page apps
Web ComponentsYesStyles isolated with Shadow DOM (a private style scope, explained later); scripts still share the pagePassing rich data through attributesMixed frameworks, long-lived apps
IframesYesHighLayout, deep links and loginThird-party or untrusted widgets

The platform lead picks run-time loading with Web Components as the contract. The shell needs to know only a tag name, never how the travel team built its screens.

What are the downsides of micro frontends?

The downsides are bigger downloads, more systems to run, version clashes between shared libraries, uneven screens and harder debugging. You trade waiting between teams for running more moving parts.

  • Payload size. If library sharing is off or versions do not match, the user downloads React more than once. React DOM is the companion package that draws React screens in the browser. One extra copy of React and React DOM 19.3.1 cost 68 KB (68,118 bytes) gzipped and 218 KB of JavaScript to parse in our build.
  • Operational load. Three teams now mean four pipelines, four sets of alerts and a manifest to manage. Someone must own all of it.
  • Version drift. One part moves to a new design system release while another lags. The same button looks slightly different on two screens.
  • Debugging across teams. A blank screen might be the shell, a bundle, the CDN or a contract change. Errors need to say which part failed.
  • Local setup gaps. A developer running one part alone may miss problems that show only when all parts share a page. Test in a production-like setup early.

The platform lead plans for the worst case: a part that fails to load. The shell catches the failed load, so a broken insurance bundle shows a short message and a retry button. Travel and bill-pay keep working.

When should you use micro frontends instead of one app?

Use micro frontends when several teams own clearly separate business areas and keep blocking each other's releases. If one or two teams own the app, keep one app with clear folders and module rules.

The split solves a people problem, not a code problem. Micro frontends let teams move without waiting. If nobody is waiting, you pay the costs for nothing.

The same thinking applies on the backend, where splitting a monolith (one big app) into microservices helps teams but adds network calls and operations. The frontend version adds pressure on the user's download.

Here is the decision path the platform lead uses. Start at the top and follow the answers for your own team.

Should you split your frontend?

Notice how many arrows end at one modular app. Most small teams stop at the first or second question.

Diagram connections:

  • Three or more teams? to Releases often blocked? (Yes)
  • Three or more teams? to Keep one modular app (No)
  • Releases often blocked? to Clear area boundaries? (Yes)
  • Releases often blocked? to Keep one modular app (No)
  • Clear area boundaries? to Can fund a platform team? (Yes)
  • Clear area boundaries? to Keep one modular app (No, fix boundaries first)
  • Can fund a platform team? to Split one section first (Yes)
  • Can fund a platform team? to Keep one modular app (No)
  • Three or more teams?: Each owning its own product area
  • Releases often blocked?: By other teams' changes or tests
  • Clear area boundaries?: Travel, bill-pay, insurance and so on
  • Can fund a platform team?: To own shell, rules and pipelines
  • Keep one modular app: Clear folders, owners and lint rules
  • Split one section first: Measure release slips before splitting more

The super-app passes every question. Three teams own three clear areas, releases slip often, and the manager funds two engineers for the platform team. Six months later, travel releases about 3 times a week instead of once (made up).

A modular single app is the honest alternative. You keep one build, but give each team its own folder, code owners and lint rules (automatic code checks) that stop imports across areas. Many teams never need more.

How do you build a micro frontend as a Web Component?

You wrap your team's app in a custom element: a class that extends HTMLElement, registered with customElements.define under a name that contains a hyphen. The browser then calls your code when the tag appears or leaves.

MDN lists the lifecycle callbacks the browser calls for you. connectedCallback runs each time the element is added to the page. disconnectedCallback runs each time it is removed. attributeChangedCallback runs when an observed attribute changes.

Here is the travel team's entry file. renderTravel and unmountTravel are stand-ins for the team's own code, which can use any framework inside.

typescript
import { renderTravel, unmountTravel } from "./TravelApp";

class TravelApp extends HTMLElement {
  static observedAttributes = ["user-city"];
  private mounted = false;

  connectedCallback(): void {
    this.mounted = true;
    this.render();
  }
  attributeChangedCallback(): void {
    if (this.mounted) this.render();
  }
  disconnectedCallback(): void {
    this.mounted = false;
    unmountTravel(this);
  }
  private render(): void {
    renderTravel(this, { city: this.getAttribute("user-city") ?? "Pune" });
  }
}
if (!customElements.get("travel-app")) customElements.define("travel-app", TravelApp);

Three parts matter. observedAttributes lists the only input the shell may pass, so the contract is visible in code. The mounted flag stops an early render, and the last line guards against a second registration.

Why the flag? MDN notes that attributeChangedCallback also fires when the tag is first parsed with that attribute. In our jsdom run it fired before connectedCallback, so without the flag the screen rendered twice.

The guard matters because a second customElements.define with the same name throws a NotSupportedError, and a name without a hyphen throws a SyntaxError. MDN documents both, and jsdom 30.1.3 threw both.

What the travel-app element did in jsdom 30.1.3 (TypeScript 7.0.2 strict, compiled and run on 19 October 2026)
ActionCalls recordedNote
Shell inserts the tag with user-city Jaipurrender JaipurOne render, after the element is on the page
Same, with the mounted flag removedrender Jaipur, render JaipurThe first render ran before connectedCallback
Shell changes user-city to Kochirender KochiScreen text became Trains from Kochi
Shell removes the tagunmountCleanup ran
Tag was on the page before the script loadedrender JaipurThe browser upgrades existing tags when the name is defined

jsdom is a browser simulation that runs in Node.js (JavaScript outside the browser), not a real browser. To check a real page, open the browser's developer tools and type customElements.get("travel-app") in the console. It returns the class once the travel bundle has loaded, and undefined before.

How do micro frontends share React without loading it twice?

They share it through a run-time sharing system, such as webpack's Module Federation. Separate builds put the libraries they offer into one shared scope, and each part takes a copy from there by the rules below.

Module Federation lets separate builds form one app. The webpack docs say a build can expose modules and load modules from other builds, called remote containers, at run time. A shared scope is the list of offered library versions that all builds see.

Three settings decide what happens. singleton makes every build use one copy of the library. The webpack docs give the reason: React and React DOM keep global internal state. requiredVersion is the version range a build accepts, such as ^18.3.0 (any 18.x from 18.3).

For a singleton, strictVersion turns a version mismatch into a run-time error. Without it, a singleton that does not fit only prints a console warning and is used anyway.

Here is the travel team's webpack config. It accepts React 18.3 and later 18.x, or any 19.x, and asks for one shared copy of React and React DOM.

javascript
const { ModuleFederationPlugin } = require("webpack").container;

module.exports = {
  mode: "production",
  entry: "./src/index.js",
  plugins: [
    new ModuleFederationPlugin({
      name: "travel",
      filename: "remoteEntry.js",
      exposes: { "./entry": "./src/entry.js" },
      shared: {
        react: { singleton: true, requiredVersion: "^18.3.0 || ^19.0.0" },
        "react-dom": { singleton: true, requiredVersion: "^18.3.0 || ^19.0.0" },
        "react-dom/client": { singleton: true, requiredVersion: "^18.3.0 || ^19.0.0" },
      },
    }),
  ],
};

All three entries matter. React DOM 19.3.1 checks at start-up that React is exactly 19.3.1 and throws otherwise, so each React DOM must run with its own React. The webpack docs also say webpack shares only the root of a package by default. So react-dom/client, which React 18 and 19 apps import, needs its own entry.

It depends on when the travel bundle joins the shared scope. We built both sides with webpack 5.112.0 and loaded them in jsdom, not a real browser. Each part drew its own screen with createRoot from react-dom/client. These results are for webpack's built-in ModuleFederationPlugin. We did not test the separate Module Federation 2.0 runtime, so test it on your own setup.

Which React each part ran and whether it rendered: webpack 5.112.0 built-in Module Federation, run in jsdom 30.1.3, not a real browser, as of 19 October 2026
SetupShell runsTravel runsWhat you see
Travel listed in the shell's config; all three shared as singletons; travel wants ^1919.3.1, travel's copy19.3.1Both render, but the shell runs a React it never asked for; two console warnings
Same, with strictVersion on in the shellFails to load ReactNot reachedRun-time error when the shell asks for React; nothing renders
Travel loaded later from the manifest; all three shared as singletons; travel wants ^1918.3.118.3.1Both render; travel runs on React 18; two console warnings
Same, with strictVersion on in travel18.3.1Fails to loadRun-time error the shell can catch
Travel loaded later; accepts ^18.3.0 or ^19.0.018.3.118.3.1One copy, both render, no warning
Travel listed in the shell's config; accepts ^18.3.0 or ^19.0.019.3.1, travel's copy19.3.1Both render, but the shell still moves to 19.3.1; two console warnings
Not a singleton; travel wants ^1918.3.119.3.1Both render, with two copies of React and React DOM on one page
Only react shared; travel loaded later; accepts ^18.3.0 or ^19.0.018.3.1Crashes on loadTravel's own React DOM 19.3.1 meets React 18.3.1; travel stays blank with no warning
Only react shared; travel listed in the shell's config; same rangeCrashes on loadNot reachedThe shell's React DOM 18.3.1 meets React 19.3.1; one warning, then a blank page

What just happened? When the shell's config lists a remote, webpack registers that remote's libraries as soon as sharing starts. No copy is in use yet, so the highest singleton version wins, even in the shell.

When the shell loads a part later from a manifest, the shell's copy is already in use. The webpack concepts page says an already-loaded version is never replaced. The late part gets that copy or an error.

The warning is easy to miss. In the manifest row where travel wants ^19, the console showed: Unsatisfied version 18.3.1 from shell of shared singleton module react (required ^19.0.0). A second line named react-dom/client. The worst case prints nothing. With only react shared, travel stayed blank and the console stayed silent. So after every release, check that each part actually renders, and also search the console for Unsatisfied version.

A part written for React 19 that silently runs on React 18 can break wherever it uses a React 19 feature. That is why the travel team states the widest range it has actually tested. On the manifest path, that removes the warning. A statically listed remote still pushes its higher version onto the shell.

Loading the travel micro frontend from the manifest

This is the singleton path. Watch the decision box: a copy already in use always wins, and a range that does not fit only warns unless strictVersion is on.

Diagram connections:

  • User taps Travel to Look up bundle address
  • Look up bundle address to Fetch remoteEntry.js
  • Fetch remoteEntry.js to Join the shared scope
  • Join the shared scope to React already in use?
  • React already in use? to Use the loaded copy (Yes)
  • React already in use? to Use the highest version (No)
  • Use the loaded copy to Mount travel-app tag
  • Use the highest version to Mount travel-app tag
  • User taps Travel: Browser → Shell
  • Look up bundle address: Shell → Release manifest
  • Fetch remoteEntry.js: Shell → CDN
  • Join the shared scope: Shell → Travel container (init)
  • React already in use?: Travel bundle → Shared scope
  • Use the loaded copy: Shared scope → Travel; warns if range fails
  • Use the highest version: Shared scope → Travel; warns if range fails
  • Mount travel-app tag: Shell → Travel bundle

How big is a second copy of React?

We built a page that only renders one React 19.3.1 screen with React DOM, using webpack 5.112.0 in production mode. Its src/index.js imports createElement from react and createRoot from react-dom/client. The bundle was 218,492 bytes minified and 68,118 bytes after gzip -9. With webpack-cli installed and an entry file like ours, you can measure the same way.

bash
npx webpack --mode production
gzip -9 -c dist/main.js | wc -c
# our run, webpack 5.112.0, React and React DOM 19.3.1:
# 68118

Almost all of that is React DOM. An entry that imports only React's createElement came to 932 bytes after gzip -9. So every part that bundles its own React DOM, as in the not-a-singleton row, adds roughly 68 KB for users.

What about import maps?

Import maps are the browser's own option. An import map is a JSON block in the page that maps a short name, such as react, to a file address. MDN marks import maps as available across browsers since March 2023.

Several teams can each add an import map, and the browser merges them, as long as each map comes before the modules that use it. MDN says an earlier mapping wins when two maps name the same module. Per MDN's compatibility data (8.1.6), all four major browsers support multiple maps since Firefox 150 in April 2026.

How do micro frontends talk to each other?

They talk as little as possible, through a few small, typed messages such as browser custom events, the URL, or data the shell passes down. They should not reach into each other's code or share one big store.

Here is the super-app's case. When a user books a flight, the insurance team wants to offer travel cover. The travel part should not import insurance code. It announces that a trip was booked, and anyone may listen.

The platform team puts the event contract in a tiny shared package. It holds names and types only, with no business logic.

typescript
export const TRIP_BOOKED = "travel:trip-booked";

export interface TripBooked {
  bookingId: string;
  destination: string;
  departsOn: string; // ISO date, e.g. "2026-12-20"
}

export function emitTripBooked(detail: TripBooked): void {
  window.dispatchEvent(new CustomEvent<TripBooked>(TRIP_BOOKED, { detail }));
}

export function onTripBooked(handler: (trip: TripBooked) => void): () => void {
  const listener = (e: Event) => handler((e as CustomEvent<TripBooked>).detail);
  window.addEventListener(TRIP_BOOKED, listener);
  return () => window.removeEventListener(TRIP_BOOKED, listener);
}

The travel: prefix keeps event names from clashing with other scripts. The TripBooked interface is the contract. When we renamed departsOn to departOn in a caller, TypeScript 7.0.2 strict failed the build with error TS2561.

onTripBooked returns a function that removes the listener. The insurance part calls it in its own disconnectedCallback. In our jsdom run, a listener heard B1, was removed, then did not hear B2.

Other channels fit other jobs. The URL carries state a user may bookmark, such as the selected route. The shell passes down shared facts, such as the logged-in user and language. Anything bigger is a sign the split line is wrong.

What rules keep micro frontends looking like one app?

A small set of rules owned by the platform team keeps the parts consistent: one design system, isolated styles, one login, shared routing and size budgets. Inside those rules, each team chooses freely.

  • Design system. Shared buttons, colours, fonts and spacing, published as a versioned package. A design system is a library of ready UI pieces with rules for using them.
  • Style isolation. Each part keeps its CSS to itself, with prefixed class names, CSS Modules or Shadow DOM. Global styles belong only to the shell.
  • One login. The shell signs the user in once and passes the session down. No part shows its own login screen.
  • Routing. The shell owns the top-level address, such as the travel path. Each part owns the paths below its own section.
  • Budgets. Each part has a size limit for its first download, checked in its own pipeline. A breach blocks that team's release, not everyone's.

Shadow DOM is a browser feature that gives an element its own private tree of markup and styles. MDN says page CSS does not reach inside it, and its styles do not leak out.

There is one exception worth knowing. A web.dev article on Shadow DOM says inheritable styles, such as colour and font, still flow in from the page. So set the base font once in the shell.

Jackson's article also advises patience with shared components. Let patterns emerge in each team first. Move a piece into the design system once its shape is clear, and keep business logic out of it.

The move itself happens one section at a time, which Jackson calls strangling the old app piece by piece.

  1. Move the shell out first

    The platform team pulls the header, login and routing into a new shell. The old single app still runs every section inside it.

  2. Split off one section

    Travel goes first, because it releases most often. The travel team wraps its screens in the travel-app tag and gets its own pipeline.

  3. Run both for a while

    The shell can load travel from the old app or the new bundle. A setting switches users over, and back if something breaks.

  4. Add contracts and budgets

    The trip-booked event, the shared React range and a size budget go in before the second split, while there is one example to learn from.

  5. Split the rest, if it still pays

    Bill-pay and insurance follow only if travel's releases show fewer slips. If they do not, the team stops and rethinks.

A big-bang rewrite into micro frontends can freeze features for months. Moving one section at a time lets the company stop at any point with a working app.

How do you explain micro frontends in a system design interview?

Explain them as a trade: independent releases for teams, paid for with a shell, contracts and extra operations. Walk through the design in this order.

  1. State the team problem

    Say how many teams share the app and how releases block each other. Without that, recommend one modular app.

  2. Draw the split lines

    Split by business area, such as travel, bill-pay and insurance. Give each area one owning team.

  3. Add the shell

    The shell owns the frame, login, routing and a release manifest that points to each part's current bundle.

  4. Pick a composition method

    Name your choice, such as run-time loading with Web Components, and say why you rejected build-time packages and iframes.

  5. Define the contracts

    Show the tag and its attributes, plus a few typed events. Say which libraries are shared as one copy, and with which version range.

  6. Cover failure and performance

    Explain what users see when one part fails to load, and how size budgets protect slow phones.

  7. Describe the migration

    Move one section at a time behind the shell, with a switch to roll back.

Questions people ask

Are micro frontends the same as microservices?
No, but they follow the same idea. Microservices split the backend into services that teams deploy on their own. Micro frontends split the user interface the same way. A team often owns both its micro frontend and the services behind it, so one business area has one owner end to end.
Can different micro frontends use different frameworks?
Yes, technically, especially with Web Components as the contract, because the shell only sees a tag. In practice, most teams pick one main framework. Mixed frameworks mean more code for users to download and fewer shared components. Keep the option for migrations, such as moving one old section to a new framework step by step.
Is Module Federation required for micro frontends?
No. Module Federation is one way to load parts and share libraries at run time. You can also load plain scripts listed in a manifest, use the browser's import maps, compose HTML on the server, or use iframes. Pick the method that fits your build tools and isolation needs.
Do micro frontends hurt SEO?
They can, if the main content appears only after several scripts run in the browser. Google renders pages with a headless Chromium browser, but its own guide says not all bots can run JavaScript. For pages that must rank, render the main content on the server.
How do you test micro frontends?
Test each part on its own with unit and component tests in its own pipeline. Then check the contracts: type-check the shared event types and test the tag's attributes. Keep a few end-to-end tests that load the shell with all parts together. Check that each part actually draws its screen, and fail the run on any Unsatisfied version warning.
How small should a micro frontend be?
Size it to a team and a business area, not to a screen or a component. One team owning one area, such as travel, is a good unit. Parts that are too small need heavy communication and more pipelines than the split saves.
Should micro frontends share state?
Share as little as possible. The shell can pass down shared facts like the logged-in user and language. Other updates should travel as small events or in the URL. One global store shared by all teams ties their releases together again, which defeats the purpose of splitting.

Sources and further reading

Practise this with Lala

Practise