← Architecture
Frontend applications Two teams, one page

Micro-frontends

The shell owns the page.

You already put code on your pages that another team ships whenever it likes: a payment form, a chat bubble, a video player. Their release lands in your page without your deploy. Micro-frontends are that arrangement between two teams in your own company. Let’s give a listing page a widget from another team and watch what their bad day does to it.

TypeScriptGoOne listing page, two shells, two recorded agent runs.

01 / The prompt

“Load the other team’s widget at runtime so they can ship on their own.”

A rental site. The listings team owns the page for apartment 4B: the rent, the photos, the Apply button. The tours team builds the “Book a viewing” calendar, and they are tired of waiting for the listings team’s release train. So the page loads their widget at runtime from wherever they deploy it. What comes back works: the calendar appears, and the tours team ships a fix on a Tuesday afternoon without asking anyone.

Then they ship a release that throws when it mounts. Or one that expects propertyId where the page passes listingId, because their contract changed and nobody told the page. Or their server is slow. The question the prompt never answered is whose page it is. If the answer is “whatever loaded last”, the tours team’s bad release takes the rent and the Apply button down with it.

You already live with this, at the edge of your pages. Stripe’s Payment Element is mounted into your page with paymentElement.mount("#payment-element"), and Stripe “keeps each payment method provider’s requirements up to date” (Stripe Web Elements, fetched 23 September 2026). That is UI another team changes inside your page, on its schedule. The question is what your page does on the day it misbehaves.

02 / Name the shape

The shell owns the page.

A micro-frontend is a part of a page that one team builds and deploys on its own, composed into a page another team owns. The team that owns the page runs the shell: the code that decides what the page is, where each part goes, and what happens when a part fails. The other team owns what is inside its region, and nothing outside it.

The shell renders its own parts without waiting, loads the release built for the contract it speaks, and gives the widget a region, a budget, and a fallback. The widget can fail; the page cannot.

Who owns each part of the listing page
PartOwnerWhy
The page, the rent, the Apply buttonListings team (the shell)It is their page; renters apply here even when the calendar is down.
The viewing calendar’s codeTours teamThey ship it on their schedule.
The contract: props, exports, versionBoth, written downIt is the only thing each team may rely on.
Which release a page loadsThe manifest, published by toursEach release is listed under the contract it was built for.
The budget and the fallbackThe shellOnly the page’s owner can decide what the page does without the widget.

Words to put in a prompt or a review

Shell (host)
The code that owns the page and composes the parts.
Remote
A part another team deploys, loaded at runtime.
Contract
What a remote exports and the props it expects, with a version number.
Manifest
The published list of which release to load, per contract.
Boundary
Where a remote’s failure stops and the fallback starts.
Budget
How long the shell will wait before it shows the fallback.
Module Federation, single-spa, iframes, or a linkWays to compose

The shape does not depend on the mechanism. Module Federation shares code at runtime between bundles; single-spa mounts whole applications into regions; an iframe gives the strongest isolation and the weakest integration; and a plain link to a page the other team owns is the cheapest composition of all. Each still needs the shell’s three decisions: which release, how long to wait, and what to show without it.

03 / Follow one release

Watch the other team’s release reach two pages.

The same tours deployment, loaded by two shells side by side: one that trusts the latest release and waits for it, one that owns the page. A normal day, a broken patch, a new contract, and a slow server. Step through, or open Try it and be the tours team.

Micro-frontends

Whose bad day is it?

Tours team latest 1.4.0 · manifest contract 1 → 1.4.0 · server up · listings on contract 1

Trusting shell · ready at 200 ms

Apartment 4B · $1,450 a month

Tours 1.4.0 Viewings for 4B: Sat 10:00, Sat 11:00

[ Apply ]

Guarded shell · ready at 0 ms

Apartment 4B · $1,450 a month

Tours 1.4.0 Viewings for 4B: Sat 10:00, Sat 11:00

[ Apply ]

01/ 04
A normal day

Both shells load the listing.

Tours 1.4.0 is live. The trusting page is ready at 200 ms, when the widget is; the guarded page at 0 ms, with the calendar at 200.

Reduced motion: choose a scene to see its completed state.

Read this scene

Tours 1.4.0 is live. The trusting page is ready at 200 ms, when the widget is; the guarded page at 0 ms, with the calendar at 200.

A normal day. Both shells load the listing. Tours latest 1.4.0; manifest contract 1 → 1.4.0; server up. Trusting page: Apartment 4B · $1,450 a month | [calendar] Viewings for 4B: Sat 10:00, Sat 11:00 | [Apply]. Guarded page: Apartment 4B · $1,450 a month | [calendar] Viewings for 4B: Sat 10:00, Sat 11:00 | [Apply].

Watch restarts the story when you come back. Step through keeps your step. Try it starts a fresh deployment each time you open it.

04 / Read the shape

A region, a budget, and a fallback.

Basic form is the region the shell hands a widget: every check happens before the widget’s code runs, and every failure becomes the fallback. In the wild is the guarded shell: its own parts first, then the manifest’s release for its contract. At the call site is the page a renter sees, which has the rent and the Apply button in every case.

The widgets here are plain objects with a mount function, and the network is a latency number, so the story can be exact. The React and Svelte samples in section 09 do the same thing with import(), a timer, and the DOM.

The region the shell gives a widget: a budget, a contract check, a boundary, and a fallback. The widget’s own code runs only after the checks pass, and any failure becomes the fallback. Go contains a panic the same way it contains an error.

TypeScriptReading
listing-page.ts
export interface WidgetProps {
	listingId?: string;
	propertyId?: string;
}
/** What every release of the viewing widget exports. `contract` says which props it expects. */
export interface RemoteModule {
	contract: number;
	mount(props: WidgetProps): string;
}

export const BUDGET_MS = 1500;
export const FALLBACK = 'Book a viewing by phone: (555) 010-0142';

/** The page’s own parts are the shell’s; the widget gets a region, a budget, and a fallback. */
export function composeRegion(
	contract: number,
	props: WidgetProps,
	remote: { module: RemoteModule; latencyMs: number } | 'down'
): Region {
	if (remote === 'down') return fallback(0, 'the widget could not be fetched');
	if (remote.latencyMs > BUDGET_MS) return fallback(BUDGET_MS, `no answer within ${BUDGET_MS} ms`);
	if (remote.module.contract !== contract)
		return fallback(remote.latencyMs, `contract ${remote.module.contract}, expected ${contract}`);
	try {
		const text = remote.module.mount(props);
		return { widget: 'calendar', text, readyMs: remote.latencyMs, error: null, mounted: true };
	} catch (error) {
		return fallback(remote.latencyMs, (error as Error).message, true);
	}
}

export interface Region {
	widget: 'calendar' | 'fallback';
	text: string;
	readyMs: number;
	error: string | null;
	/** Whether the widget's own code ran. */
	mounted: boolean;
}

const fallback = (readyMs: number, error: string, mounted = false): Region => ({
	widget: 'fallback',
	text: FALLBACK,
	readyMs,
	error,
	mounted
});
GoAlongside
main.go
type WidgetProps struct{ ListingID, PropertyID string }

// RemoteModule is what every release of the viewing widget exports.
type RemoteModule struct {
	Contract int
	Mount    func(WidgetProps) (string, error)
}

const BudgetMs = 1500
const Fallback = "Book a viewing by phone: (555) 010-0142"

type Remote struct {
	Module    RemoteModule
	LatencyMs int
}

type Region struct {
	Widget  string // calendar or fallback
	Text    string
	ReadyMs int
	Err     error
	Mounted bool // whether the widget's own code ran
}

func fallback(readyMs int, err error, mounted bool) Region {
	return Region{"fallback", Fallback, readyMs, err, mounted}
}

// ComposeRegion gives the widget a region, a budget, and a fallback. A nil
// remote means it could not be fetched.
func ComposeRegion(contract int, props WidgetProps, remote *Remote) (region Region) {
	if remote == nil {
		return fallback(0, errors.New("the widget could not be fetched"), false)
	}
	if remote.LatencyMs > BudgetMs {
		return fallback(BudgetMs, fmt.Errorf("no answer within %d ms", BudgetMs), false)
	}
	if remote.Module.Contract != contract {
		return fallback(remote.LatencyMs, fmt.Errorf("contract %d, expected %d", remote.Module.Contract, contract), false)
	}
	defer func() {
		if r := recover(); r != nil { // a widget that panics is contained like one that errors
			region = fallback(remote.LatencyMs, fmt.Errorf("%v", r), true)
		}
	}()
	text, err := remote.Module.Mount(props)
	if err != nil {
		return fallback(remote.LatencyMs, err, true)
	}
	return Region{"calendar", text, remote.LatencyMs, nil, true}
}
The behavior these examples promiseChecked by 9 shared scenarios in TypeScript and Go
  • Deploying a release makes it the latest and lists it in the manifest under its contract.
  • The trusting shell loads the latest release and waits for it. The page is ready when the widget is; if the widget cannot be fetched or its mount throws, the page is an error page.
  • The guarded shell renders at 0 ms and loads the manifest’s release for its own contract. It shows the fallback, “Book a viewing by phone: (555) 010-0142”, if the release cannot be fetched (at 0 ms), takes longer than 1,500 ms (at 1,500 ms), was built for another contract, or throws. Each fallback is reported.
  • A shell can move to contract 2, after which it passes propertyId.

Every expected result was produced by a separate model written from these rules, kept beside the examples in model/cases.py, not copied from either implementation.

Reading the TypeScriptA union for the network, and a try around mount

composeRegion takes the fetch result as { module, latencyMs } or the string 'down', so the function has to handle the outage before it can touch a module. The try surrounds only mount, the one call into the other team’s code.

Reading the GoA named result and recover

ComposeRegion has a named result so a deferred recover can replace it with the fallback if a widget panics. A widget that returns an error and one that panics end up in the same place, which is the point of a boundary.

Run it yourselfNo dependencies

Copy the complete TypeScript file and run node --experimental-strip-types listing-page.ts with Node 22.18 or later. For Go, save main.go next to this go.mod and run go run .. Both print:

go.mod
module micro-frontends

go 1.22
broken patch, trusting: Something went wrong. Please try again. (page at 200 ms)
broken patch, guarded: Apartment 4B · $1,450 a month | [fallback] Book a viewing by phone: (555) 010-0142 | [Apply] (page at 0 ms)
slow server, trusting: Apartment 4B · $1,450 a month | [calendar] Viewings for 4B: Sat 10:00, Sat 11:00 | [Apply] (page at 4000 ms)
slow server, guarded: Apartment 4B · $1,450 a month | [fallback] Book a viewing by phone: (555) 010-0142 | [Apply] (page at 0 ms)

05 / Review the agent’s diff

“Simplified widget loading.”

A manifest is one more file and one more request, and an agent tidying the loader will notice. Read what the shell can no longer ask for once it is gone.

The agent’s pull request

“Simplified widget loading: the shell loads the tours widget’s current build directly, so the manifest file and its extra request are gone. Tests pass.”

// shell/viewings.ts
			(removed)const manifest = await (await fetch('/widgets/manifest.json')).json();
			(removed)const url = manifest[`viewings@${CONTRACT}`];
			(added)const url = 'https://widgets.example.com/viewings/current/index.js';
			const remote = await withBudget(import(url), BUDGET_MS);
			(removed)if (remote.contract !== CONTRACT) throw new ContractError(remote.contract);
			return remote;
			
You are reviewing this change. What do you do?

06 / How it fails

The other team’s failure should be the other team’s region.

Here is each way the tours widget can go wrong, what a renter sees, and what the guarded shell does.

Failure modes of one widget load
What goes wrongWhat the renter seesWhat the guarded shell doesBacked by
SlowThe page at once; the phone number after 1.5 s.Stops waiting at the budget.Case “the tours server is slow”
DownThe page and the phone number.Falls back at once and reports it.Case “the tours server is down”
Wrong: a broken releaseThe page and the phone number.Catches the throw from mount.Cases “a broken patch”, “a broken release on the new contract”
Wrong: a new contractThe old calendar, still working.Loads the release built for its contract.Case “the tours team ships a new contract”
Out of step: the page upgrades firstThe phone number.Finds no release for contract 2 and falls back.Case “the listings team upgrades before the tours team ships”
Rolled backThe calendar again.Loads what the manifest now lists.Case “a broken patch, then a rollback”

Containing failure is the same idea for a slow dependency on the server, and Error boundaries in UI covers the framework mechanics of catching a render error.

07 / Is it worth it?

You pay in contracts and bytes. Here is what it buys.

A runtime split costs a manifest, a contract to version, a fallback to design, often a second copy of a framework in the browser, and a second pipeline. The simpler shape is one app with two folders and one deploy. Hold the three against the changes a page like this always gets.

The same four changes, made to each shape
ChangeOne app, two foldersTrusting shellGuarded shell
A second page wants the calendar: search resultsImport the component.Load latest; inherits every bad release.Load its contract’s release.
The tours team replaces its calendar libraryOne shared build and release.Their deploy; may break the page.Their deploy; at worst, the fallback.
A rule changes: viewings need an emailBoth teams edit one release.A contract change nobody versioned.Contract 2, shipped before the page moves.
A third team joinsMore people on one pipeline.Another way to break the page.Another region with its own budget.

Before splitting a page, decide what you will look at:

  • Lead time for a tours change, from merge to production, before and after. This is the benefit the split exists for; if it does not move, the split is cost with no return.
  • Listing pages that failed to render because of the widget. The accepted number is zero; the fallback count is the number to watch instead.
  • JavaScript bytes on the listing page, before and after. Two copies of a framework show up here.

This page did not run a real site, so it has no numbers to give you. The first measurement is the one to take before anything else: if the tours team’s lead time is already fine, the split is not for them.

08 / Ask for it

Two prompts, two listing pages, one bad day.

We sent two agents the same request for this page at the same time, both running Claude Sonnet. The shared prompt described the page, how the tours team deploys (a folder per release and a releases.json with each release’s contract), and what a release exports. The architecture prompt added the shell: its own parts at once, the newest release for contract 1, a contract check, a 1.5 s budget, and a phone-number fallback. Then a script played the tours team against each build in Chromium.

What the checker found, run 2026-09-23
What the tours team didPlain promptArchitecture prompt
The first loadrent and Apply shown after under 1 s; calendar shownrent and Apply shown after under 1 s; calendar shown
The tours team ships a release that throwsrent and Apply shown after under 1 s; widget area says “Viewing times are unavailable right now.”rent and Apply shown after under 1 s; widget area says “Book a viewing by phone: (555) 010-0142”
The tours team ships a new contractrent and Apply shown after under 1 s; widget area says “Viewing times are unavailable right now.”rent and Apply shown after under 1 s; calendar shown
The widget’s files cannot be fetchedrent and Apply shown after under 1 s; widget area says “Viewing times are unavailable right now.”rent and Apply shown after under 1 s; widget area says “Book a viewing by phone: (555) 010-0142”
The widget’s files take 4 secondsrent and Apply shown after under 1 s; widget area says “Loading viewing times…”rent and Apply shown after under 1 s; widget area says “Book a viewing by phone: (555) 010-0142”

The plain build did more than the prompt asked. It rendered the rent and the Apply button without waiting, checked the release’s contract, and caught every failure into a quiet “Viewing times are unavailable right now.” A broken patch or a down server left its page usable. The trusting shell in section 03 is the lesson’s example of what the page looks like without that care; neither agent built it.

The builds split on the two ordinary days. When the tours team shipped 2.0.0, a working release for contract 2, the plain build took the newest release, saw a contract it did not speak, and dropped the calendar: a normal deploy by the other team looked, to every renter, like an outage. The architecture build asked for the newest release for contract 1 and kept showing 1.4.0’s calendar. And with the widget’s files taking 4 seconds, the plain build still said “Loading viewing times…” two and a half seconds in, with no budget; the architecture build had given up at 1.5 seconds and shown the phone number.

server.js (inline shell) · plain prompt
const latest = releases[releases.length - 1];
if (
  !latest ||
  typeof latest.version !== 'string' ||
  typeof latest.contract !== 'number'
) {
  throw new Error('malformed release entry');
}
if (latest.contract !== SUPPORTED_CONTRACT) {
  throw new Error(
    'unsupported tours widget contract: ' + latest.contract
  );
}

const moduleUrl = '/widgets/viewings/' + latest.version + '/index.js';
listing.html · architecture prompt
const contractOneReleases = releases.filter(
  (r) => r && typeof r === "object" && r.contract === CONTRACT
);
if (contractOneReleases.length === 0) {
  throw new Error("no release with contract " + CONTRACT + " was found");
}
const release = contractOneReleases[contractOneReleases.length - 1];

The line that made the difference is about the other team’s schedule, not about failure: load the newest release whose contract is the one this page speaks, not simply the newest release. Independent deploys only stay independent if a new contract can ship before every page moves to it.

How the runs were made and checkedOne run each, one checker run
  • Both agents received the prompts word for word, in fresh contexts, at the same time. The only differences were the Architecture block and the output folder.
  • The checker plays the tours team: it writes a broken 1.4.1 and a working 2.0.0 into each restored build the way the prompt says the tours team deploys, and aborts or delays the widget’s files in Chromium’s router.
  • Neither agent opened its page in a browser; the architecture agent said so and named the budget race as the thing it could not test. The checker tested it.
  • The architecture agent wrote a copy of its page, a server log, and an extracted script to the system temp folder, outside its own folder. Neither agent used a pattern kill.
  • One run of each prompt is a sample, not a measurement of a model.

09 / Hold it there

Keep the split real, and keep the page up.

Two things erode a micro-frontend: the shell quietly importing the widget’s source, which ends the independent deploy, and a loader change that drops the contract, which ends the page’s safety. Three kinds of check hold both.

  1. The framework’s own door

    React’s error boundaries catch errors while rendering a subtree, and <Suspense> shows a fallback while a lazy component loads. Svelte’s <svelte:boundary>, added in 5.3.0, catches errors “during rendering and effect execution”, and its docs say it does not catch errors in event handlers or after async work (svelte:boundary, fetched 23 September 2026). So a failed import() needs its own handling in both, which is why the samples below catch it explicitly.

  2. An import rule an agent cannot argue with

    The shell may load the widget through the manifest and never import its source; one import and the two teams are back on one release. This rule, run with dependency-cruiser 18.3 against a three-file fixture, flagged the listing module that imported a helper from the widget, and nothing else. Enforcement layer runs rules like this against real code.

    .dependency-cruiser.cjs
    // .dependency-cruiser.cjs
    module.exports = {
    	forbidden: [
    		{
    			name: 'shell-loads-widgets-only-at-runtime',
    			comment: 'The tours widget ships on its own. The shell may load it through the manifest, never import its source.',
    			severity: 'error',
    			from: { path: '^src/shell/' },
    			to: { path: '^src/widgets/' }
    		}
    	]
    };
    depcruise output
      error shell-loads-widgets-only-at-runtime: src/shell/listing.js → src/widgets/viewings/index.js
    
    x 1 dependency violations (1 errors, 0 warnings). 3 modules, 1 dependencies cruised.
  3. A check on what actually happens

    An import rule cannot see a shell that waits forever. So deploy a broken release on purpose, in a test environment, and assert the rent and the Apply button still appear, quickly. The checker in section 08 does exactly that to the recorded builds, and the lesson’s spec does it to both shells.

    check-runs.mjs
    async 'the tours team ships a broken patch'(page, dir) {
    	deploy(dir, '1.4.1');
    	const r = await open(page);
    	return { ...r, verdict: describe(r) };
    },
Where this lives in React and SvelteEvery third-party widget you embed is a micro-frontend you do not own. A second in-house team is when you own the shell.

Where it already is in your components

The payment form you mount from a provider’s script, the support chat bubble, the embedded video player, the map. Each is UI another team ships into your page on its own schedule, behind a contract: a script URL, a mount call, a few options. You already decide what your page does when one of them is slow: a checkout that waits for a chat widget before showing the Pay button is a page that trusts the wrong team.

When you have to own it

The day a second team in your company ships a part of your page. Now you are the shell. Put the widget behind lazy() and <Suspense> inside an error boundary, or an {#await} inside <svelte:boundary>; ask a manifest for the release built for your contract; give it a budget; and check its exported contract before you call its mount. Your own parts render first, every time.

A remote widget loaded with import() behind a fallback. React wraps lazy() and Suspense in an error boundary; Svelte uses an await block for a failed import and svelte:boundary for a render error. The rent and Apply never wait for it.

ReactAlready in your code
ListingPage.tsx
import { Component, Suspense, lazy, type ComponentType, type ReactNode } from 'react';

// A widget another team deploys, loaded at runtime. The page wraps it in a
// boundary and a fallback, so their bad day is a missing panel, not a blank page.
const WIDGET_URL = 'https://widgets.example.com/viewings/current/index.js';
const ViewingCalendar = lazy(
	() =>
		import(/* @vite-ignore */ WIDGET_URL) as Promise<{
			default: ComponentType<{ listingId: string }>;
		}>
);

class WidgetBoundary extends Component<
	{ fallback: ReactNode; children: ReactNode },
	{ failed: boolean }
> {
	state = { failed: false };
	static getDerivedStateFromError() {
		return { failed: true };
	}
	componentDidCatch(error: Error) {
		console.error('viewing widget failed', error);
	}
	render() {
		return this.state.failed ? this.props.fallback : this.props.children;
	}
}

export function ListingPage() {
	const phone = <p>Book a viewing by phone: (555) 010-0142</p>;
	return (
		<main>
			<h1>Apartment 4B · $1,450 a month</h1>
			<WidgetBoundary fallback={phone}>
				<Suspense fallback={<p>Loading viewing times…</p>}>
					<ViewingCalendar listingId="4B" />
				</Suspense>
			</WidgetBoundary>
			<button type="button">Apply</button>
		</main>
	);
}

10 / Make the call

Split a page when teams, not code, are waiting on each other.

One app with two folders is the better answer until the second team’s releases are really blocked by the first team’s. It has one build, one copy of every library, and no contract to version. Split when the measured cost is lead time: the tours team waits days for a release train to ship a one-line fix, and that delay matters to the business.

If you do split, the shell’s three decisions are not optional: which release, how long to wait, and what the page does without it. Reopen the split if the tours team’s lead time did not fall, or if most changes still need both teams.

Take it with you

Explain it without saying “micro-frontend”: “The listing page is ours. The viewing calendar belongs to another team and ships whenever they like, so our page loads the version built for our contract, waits a second and a half at most, and shows a phone number if it is not there.” Then look at the widgets on your own pages and ask what each page does when one of them is slow.

Paste into your next prompt, and fill in the blanks

The <page> is a shell that owns the page: <its own parts> render at
once and never wait for <the widget>.
<The other team> publishes each release under the contract it was
built for, in a manifest. The shell loads the newest release for
the contract it speaks (<contract 1>) and checks the module's
exported contract before mounting it.
Mount it inside a boundary with a <1.5 s> budget. If it cannot be
fetched, is late, is the wrong contract, or throws, show <fallback>
in its place and report the reason. Nothing the widget does may
break the rest of the page.
Connections to follow nextRelated lessons

Take the listing page into your editor. Have the tours team ship contract 2, and move the page over without a moment where either team’s release breaks the other’s.

Back to architecture →