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.
| Part | Owner | Why |
|---|---|---|
| The page, the rent, the Apply button | Listings team (the shell) | It is their page; renters apply here even when the calendar is down. |
| The viewing calendar’s code | Tours team | They ship it on their schedule. |
| The contract: props, exports, version | Both, written down | It is the only thing each team may rely on. |
| Which release a page loads | The manifest, published by tours | Each release is listed under the contract it was built for. |
| The budget and the fallback | The shell | Only 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.
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 ]
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.
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
}); 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:
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.
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.
| What goes wrong | What the renter sees | What the guarded shell does | Backed by |
|---|---|---|---|
| Slow | The page at once; the phone number after 1.5 s. | Stops waiting at the budget. | Case “the tours server is slow” |
| Down | The page and the phone number. | Falls back at once and reports it. | Case “the tours server is down” |
| Wrong: a broken release | The page and the phone number. | Catches the throw from mount. | Cases “a broken patch”, “a broken release on the new contract” |
| Wrong: a new contract | The 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 first | The phone number. | Finds no release for contract 2 and falls back. | Case “the listings team upgrades before the tours team ships” |
| Rolled back | The 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.
| Change | One app, two folders | Trusting shell | Guarded shell |
|---|---|---|---|
| A second page wants the calendar: search results | Import the component. | Load latest; inherits every bad release. | Load its contract’s release. |
| The tours team replaces its calendar library | One shared build and release. | Their deploy; may break the page. | Their deploy; at worst, the fallback. |
| A rule changes: viewings need an email | Both teams edit one release. | A contract change nobody versioned. | Contract 2, shipped before the page moves. |
| A third team joins | More 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 tours team did | Plain prompt | Architecture prompt |
|---|---|---|
| The first load | rent and Apply shown after under 1 s; calendar shown | rent and Apply shown after under 1 s; calendar shown |
| The tours team ships a release that throws | rent 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 contract | rent 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 fetched | rent 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 seconds | rent 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.
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'; 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.
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 failedimport()needs its own handling in both, which is why the samples below catch it explicitly.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.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.
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
- Containing failure gives a slow dependency a budget and a fallback on the server side.
- Versioning and compatibility is how a contract changes without breaking the pages that speak the old one.
- Error boundaries in UI covers the framework mechanics under the boundary.
- Module contracts is the same question for two modules on the server: what may cross the line, so either side can change.
- State ownership in a component tree is the ownership question inside one team’s code.