Design a micro-frontend shell
An application shell that composes frontends built and deployed by several teams into one product: it routes URLs to apps, loads each app from a runtime manifest, mounts and unmounts it, and owns the header, navigation, and login. The hard parts are deciding whether to split at all, picking a composition method, sharing one copy of React without version fights, keeping one broken app from taking down the page, and shipping each team's code on its own schedule without making the product slow or inconsistent.
The question
Our company has grown to 12 frontend teams working on one web product: search, product pages, checkout, account, a seller dashboard, and more. Today they all ship one big single-page app from one repository, and releases are slow: one team's broken test blocks everybody. Leadership wants each team to build and deploy its part on its own.
Design the shell that makes this work: the piece that loads in the browser first, decides which team's app to show for a URL, loads that app's code, and keeps the header, navigation, and login consistent. Walk me through how apps are composed, how they share dependencies and talk to each other, what happens when one of them fails, and how deploys and rollbacks work.
Real-life examples: IKEA, Spotify's older desktop app, DAZN, Zalando Project Mosaic, FINN.no (Podium), Module Federation setups
Requirements: scoping a shell for many teams
This question sounds like a build-tool question, but it is an organization question with a frontend answer. The shell exists because many teams need to ship without waiting for each other, and every decision in it trades some of that independence against speed, consistency, or safety. So the first questions are about people and releases, not about webpack.
A micro-frontend is a part of the product (usually a whole area, like checkout) that one team builds, tests, and deploys on its own, and that runs inside a bigger page. The shell (also called the host, container, or app frame) is the small program that loads first, draws the common frame, and plugs the right micro-frontends into it. A remote is a micro-frontend that the shell loads at runtime from somewhere else, usually a CDN.
Ask how many teams there are and how often they deploy, because that tells you whether the split pays for itself at all. Ask whether pages must be found by search engines, because client-side composition renders nothing until JavaScript runs. Ask whether all apps use the same framework, because sharing one copy of React is the single biggest performance lever. Ask how isolated apps must be from each other: a shared employee portal and a page that runs code from outside vendors need very different walls.
| Question | Answer we assume | What it changes |
|---|---|---|
| How many teams, and how often do they ship? | 12 teams, 8 of them with their own area. Each wants to deploy several times a day. | Deploy independence is the main goal, so runtime composition with a manifest beats build-time packages. |
| Is it one product to the user? | Yes. One header, one login, one look. Users must not notice team boundaries. | A shared layout owned by the shell, a shared design system, and shared auth. |
| Do all teams use the same framework? | React for 11 apps. One legacy area is Angular and will stay for two more years. | Share one React copy; the contract between shell and apps must not assume React. |
| Do public pages need SEO? | Product and search pages, yes. Checkout, account, and seller tools, no. | Server or edge composition for public pages; client-side composition for the logged-in app. |
| Who writes the code that runs in the shell? | Only our own teams. No outside vendors. | Soft isolation (naming, CSS scoping, error boundaries) is enough; no iframes by default. |
| What devices and networks? | Desktop and mobile web, with mid-range Android phones on 4G as the budget device. | Every extra round trip and every duplicate library shows up in Largest Contentful Paint. |
| Can apps appear side by side on one page? | Yes on the dashboard: up to 4 widgets from different teams. | Several mounts at once, each with its own error boundary and timeout. |
Functional requirements
- Map URL prefixes to apps:
/searchto the search team's app,/checkoutto checkout, and so on, and switch apps on navigation without a full page reload. - Read a manifest at startup that says which version of each app is live and where its code is.
- Load an app's code on first use, mount it into a slot in the page, and unmount it cleanly when the user leaves its area.
- Draw the shared frame: header, navigation, account menu, footer, and global toasts.
- Own login and the session, and give every app a way to know who the user is and to make authenticated calls.
- Let apps tell each other about changes, like the cart count after an item is added on a product page.
- Show a useful fallback when an app fails to load or crashes, while the rest of the page keeps working.
Here is what the user sees. Everything outside the content slot belongs to the shell. The content slot belongs to whichever app owns the current URL, and on the dashboard it splits into widget slots owned by several teams.
Non-functional requirements
- Independent deploys: a team ships its app without rebuilding or redeploying the shell or any other app. Rollback in under a minute.
- Performance: Largest Contentful Paint under 2.5 seconds at the 75th percentile on mobile. The shell itself stays under about 60 KB of compressed JavaScript; each app's first load stays under its team budget.
- Fault isolation: one app throwing, timing out, or returning a 404 must not blank the header or another app.
- Consistency: same fonts, colors, spacing, and component behavior everywhere, from one design system.
- Observability: every error and slow interaction is tagged with the app and version that caused it, so the right team gets paged.
Out of scope
We leave out the backend services behind each app, the design system's own internals (the design-systems lesson covers them), native mobile apps, and running code from outside vendors. That last one would push us toward iframes everywhere; we will still say when iframes are the right tool.
Check your understanding · Exercise 1
Which answers from the interviewer would change the design of the shell the most? Select all that apply.
What makes a micro-frontend shell hard
A normal single-page app is built once, by one build, into one set of files. Everything inside it agrees on the versions of every library because there is exactly one package-lock.json. A shell breaks that assumption on purpose. Five properties follow from it, and every later chapter is about one of them.
- Code that meets for the first time in the user's browser. The shell and the checkout app were built on different days, by different pipelines, against different lockfiles. Nothing checked that they work together until a real user loaded both. Contracts and tests have to replace the type checker that a single build gave you for free.
- Many owners, one page. Twelve teams write CSS, register global listeners, and install polyfills into the same document. Without rules, the last stylesheet loaded wins and one team's
button { }rule restyles everybody's buttons. - Partial failure is normal. With 8 remotes on 3 CDNs, something is always slow or broken somewhere. A monolith either loads or doesn't; a shell must load the parts that work and fence off the parts that don't.
- Duplication is the default. Each app bundles its own React, its own date library, its own copy of the design system. Left alone, a user who visits four areas downloads React four times. Sharing fixes the bytes but couples the teams again, which is the very thing we split to avoid.
- Extra round trips. Before an app can render, the browser needs the shell, then the manifest, then the app's entry file, then its chunks, then its data. Each step waits for the one before. On a phone with 150 ms of round-trip time, five sequential steps cost most of a second before any data arrives.
Keep one sentence in mind for the whole interview: a micro-frontend architecture buys deploy independence and pays for it in runtime complexity. Each chapter either protects that independence or pays down part of the bill.
Think first
A single React app with React.lazy already loads each area's code on demand. What does a micro-frontend shell add that lazy loading doesn't?
Lazy loading splits the download, not the build or the release. All areas are still compiled together, tested together, and shipped in one deploy, so a broken checkout test still blocks the search team's release.
A shell splits the release. Checkout's code is built in checkout's pipeline and goes live when checkout's team flips the manifest. That is the only thing it adds, and it is the reason to pay for everything else in this guide.
Should you split at all?
Strong candidates question the premise before designing the shell. Micro-frontends solve an organizational problem: teams blocking each other on one release. If that problem isn't real, the shell is pure cost. The micro-frontends lesson covers the general idea; here we apply it to our 12 teams.
Start from the naive answer: "split everything into micro-frontends, one per team." Picture what it costs a small company. Four teams, one product, one release a week. After the split they own four build configs, four dependency upgrade schedules, four copies of the routing setup, and a shell nobody owns.
A React upgrade that used to be one pull request becomes four, plus a coordination meeting because the shared React must move in lockstep. Pages got slower because two apps still bundle their own copy of a charting library. Nobody ships faster, because nobody was blocked before.
Decision How should 12 teams organize the code of one web product?
What we pick: For 12 teams that each want to ship several times a day, and with one legacy Angular area, micro-frontends earn their cost. We still keep each app's internals in a modular monorepo per team or a shared monorepo with separate deploys (see the monorepos lesson): the two ideas combine well. For four teams on one release train, we would pick the modular monorepo and say why.
Split along areas, not along layers
Where you cut matters as much as whether you cut. Cut vertically: one team owns a whole area of the product (checkout: its pages, its components, its data calls) and its URL prefix. Don't cut horizontally into a "header app", a "buttons app", and a "forms app": every feature would then need changes in three apps from three teams, which is a slower version of the monolith.
A useful test: for the last 20 features shipped, how many areas did each one touch? If most touched one, vertical micro-frontends fit. If most touched four, the boundaries are wrong, and splitting the deploy will make every feature a cross-team release.
Check your understanding · Exercise 2
Which situations are good reasons to adopt micro-frontends with a shell? Select all that apply.
Composition: where the pieces meet
Composition is the moment separate apps become one page. It can happen at three times: when the code is built, when the server or CDN answers the request, or in the browser after the page loads. The later it happens, the more independent the teams are, and the more work the browser does at runtime.
The naive option is build-time composition: each team publishes its app as an npm package, like @acme/checkout@4.2.0, and the shell installs them all and builds one bundle. It feels clean.
Then checkout fixes a payment bug on a Friday and publishes 4.2.1. Nothing changes for users until the shell team bumps the version, rebuilds everything, and redeploys. The shell team becomes the release train we were trying to remove. Build-time packages are great for shared libraries like the design system, and wrong for whole apps that need to ship on their own.
Decision How do the shell and the apps come together into one page?
What we pick: We use client-side runtime loading for the logged-in product, with Module Federation as the loader and a framework-neutral mount contract. Public pages (product, search) use edge composition so the first HTML has content. Iframes are kept for the one case that needs a security wall, and npm packages for shared libraries only. Mixing methods by page type is normal and is the answer interviewers want to hear.
What real products have published
Public write-ups show each method in production. Engineers at IKEA have described composing pages at the edge with Edge Side Includes, so each team's HTML fragment is stitched in before the page reaches the browser. Zalando's Project Mosaic streamed fragments owned by different teams through Tailor, its open-source layout service. FINN.no built Podium, where a layout server fetches HTML fragments called podlets from each team's service.
On the client side, DAZN has described a small orchestrator that loads one vertical micro-frontend at a time. Spotify's earlier desktop client ran each page as a standalone app inside its own iframe, and in 2021 Spotify described moving desktop and web onto one shared codebase. Zalando also moved from fragments to a shared framework of React renderers to get consistency back. Companies move between methods as their teams and products change, which is one more reason to keep the app contract independent of the loading method.
Module Federation in practice
Module Federation started as a webpack 5 plugin, ModuleFederationPlugin. A build becomes a container: a remote lists the modules it exposes and the libraries it can share (shared), and a host lists the remotes it may load by name at runtime. The remote's build emits a small remoteEntry.js file, the remote entry, that the host loads first to reach everything else.
Module Federation is now also its own project. Module Federation 2.0 (the @module-federation/enhanced package) adds a runtime API you can call directly, such as loadRemote and registerRemotes, and a JSON manifest per remote, mf-manifest.json, that lists its entry, exposed modules, shared libraries, and files. It ships plugins for webpack, Rspack, and Vite; Rspack also has its own built-in plugin for the original feature set.
new ModuleFederationPlugin({
name: "checkout",
filename: "remoteEntry.js",
manifest: true, // also emit mf-manifest.json
exposes: {
"./app": "./src/mount.ts", // exports mount and unmount
},
shared: {
react: { singleton: true, requiredVersion: "^19.0.0" },
"react-dom": { singleton: true, requiredVersion: "^19.0.0" },
"@acme/design-system": { singleton: true, requiredVersion: "^7.2.0" },
},
});
Notice what the remote exposes: not a React component but a module with mount and unmount functions. That is the contract. If the remote exposed a React component, the shell would need the same React to render it, and the Angular area could never join. The architecture chapter writes this contract down in full.
Import maps: the standards-based option
An import map is a JSON block in the page, <script type="importmap">, that tells the browser which URL a bare module name like react or @acme/checkout points to. All major browsers support it. Each app is built as native ES modules with shared libraries left as bare imports; the shell writes the map from the manifest; the browser does the rest.
<script type="importmap">
{
"imports": {
"react": "https://cdn.acme.com/libs/react@19.1.0/index.js",
"@acme/search": "https://cdn.acme.com/search/3.8.1/main.js",
"@acme/seller": "https://cdn.acme.com/seller/12.0.4/main.js"
},
"scopes": {
"https://cdn.acme.com/seller/": {
"react": "https://cdn.acme.com/libs/react@18.3.1/index.js"
}
}
}
</script>
Import maps have no version negotiation: whatever the map says wins. That is simpler to reason about than Module Federation, and it puts all the version decisions in one place (the manifest service that writes the map). The cost is that libraries must be built as standalone ES modules, and the map should be in the page before the first module import runs, so it is best rendered into the HTML by the server.
Recent browsers (Chrome 127, Safari 18.4, Firefox 138, and later) also accept an integrity key in the map. It lists a Subresource Integrity hash for each module URL, so the browser refuses a module whose bytes don't match. The manifest service already knows every file it publishes, so it can write those hashes into the map; older browsers ignore the key.
Web components: a contract, not a loader
A web component (see the web-components lesson) can wrap an app: checkout registers <acme-checkout> and the shell puts the tag in the page. This gives a framework-neutral contract: attributes in, DOM events out, and an optional shadow root for CSS. It does not load code by itself; you still need one of the loaders above. And custom element names are global: two versions of a remote can't both define acme-checkout in one page, so version the tag name or keep one version per page.
Try the methods side by side. Switch the composition method and watch the bytes a user downloads after visiting three areas, how many React copies end up on the page, how isolated the apps are, and whether a team can ship alone.
| Method | Deploy alone? | React copies | Isolation | First paint | Bytes (3 areas) |
|---|---|---|---|---|---|
| Build-time packages | No | 1 | Low: one bundle | Fast with SSR | about 445 KB |
| Edge includes (SSI, ESI) | Yes | Depends on client loader | Low to medium | Fastest: HTML first | Same as its client loader |
| Module Federation | Yes | 1 if shared as singleton | Low: same document | After JS, unless SSR | about 465 KB |
| Import maps | Yes | 1 by the map | Low: same document | After JS, unless SSR | about 445 KB |
| Iframes | Yes | 1 per frame, plus the shell's | High: separate documents | Slow: one page load per frame | about 730 KB |
Check your understanding · Exercise 3
The checkout team wants to ship a hotfix at 4 pm without involving anyone else. Which composition methods allow that? Select all that apply.
The shell's job: route, load, mount, frame
The shell should be the smallest, most boring app in the company. It changes rarely, it is owned by a platform team, and if it breaks, everything breaks. Its job has four parts: map URLs to apps, know which version of each app is live, run each app through a lifecycle, and draw the shared frame. Anything else (product features, business logic, a global store of everyone's data) belongs in an app.
Top-level routing: URL prefixes to apps
Routing is split in two levels (the routing lesson covers client routers in general). The shell owns the first path segment: /search goes to search, /checkout to checkout, /sell to the seller app. Each app owns everything after its prefix with its own router. The shell never knows that /checkout/payment exists; checkout never knows about /search.
When the user clicks a link, two things can happen. If the link stays inside the current app (/checkout/address to /checkout/payment), the app's router handles it and the shell does nothing. If it crosses apps (/search to /checkout), the shell must unmount search and mount checkout.
Both apps change the URL through the History API, so the shell listens for popstate and wraps history.pushState and replaceState to notice changes (this is what single-spa does), then re-runs its route table. Longest prefix wins, so /sell/analytics can belong to a different app than /sell.
The manifest: which version is live
The shell's route table says which app owns a URL. The manifest says where that app's code is right now. Keeping them apart is the key to independent deploys: the shell's code holds no app versions at all, so shipping an app is a data change, not a code change. The manifest is a small JSON document served by a registry service, around 2 KB for our 12 apps.
{
"schema": 3,
"generatedAt": "2026-10-20T14:02:11Z",
"apps": {
"search": {
"version": "3.8.1",
"entry": "https://cdn.acme.com/search/3.8.1/mf-manifest.json",
"routes": ["/search"],
"owner": "team-discovery",
"timeoutMs": 6000
},
"checkout": {
"version": "5.2.0",
"entry": "https://cdn.acme.com/checkout/5.2.0/mf-manifest.json",
"routes": ["/checkout", "/cart"],
"owner": "team-payments",
"timeoutMs": 6000,
"fallback": { "kind": "link", "href": "/legacy/checkout" }
}
}
}
Should routes live in the manifest or in the shell's code? In the manifest, an app can claim a new prefix without a shell deploy, but a typo could let one team claim / and hide everyone else. We keep routes in the manifest and have the registry validate them on publish: no overlaps with another team's prefixes, no root claim, and only prefixes on that team's allow list.
The lifecycle: load, bootstrap, mount, unmount
Every app moves through the same states, and the shell drives them. single-spa, the best known framework for this, requires each app to export three async functions, bootstrap, mount, and unmount, with an optional unload. We use the same idea in our own contract: bootstrap runs once after the code loads (set up a store, read config); mount renders into a DOM element the shell gives it; unmount must remove everything it added: DOM, listeners, timers, and subscriptions.
mount that throws will throw again with the same code.Two states in the machine deserve attention. Load error happens when the code never arrived (404, network error, timeout); the shell may retry it on the next navigation. Broken happens when the code arrived but a lifecycle function threw; retrying the same code will fail again, so the shell shows the fallback until a new manifest version arrives. Telling these apart decides whether a retry button makes sense.
Unmount is the step teams get wrong. A React app that calls root.unmount() but forgot to remove a window resize listener leaks it; after 30 trips between search and checkout, 30 listeners fire on every resize, each holding the old app's memory. The shell can't fix that for them, but the contract test in the testing chapter can catch it.
One navigation, step by step
mount(el, ctx).async function onRouteChange(path: string) {
const next = findApp(path, manifest); // longest prefix wins
if (next?.name === current?.name) return; // the app routes inside itself
const token = ++navToken;
if (current) await lifecycle.unmount(current, slot);
if (!next) return renderNotFound(slot);
renderSkeleton(slot, next);
try {
const app = await withTimeout(lifecycle.load(next), next.timeoutMs);
if (token !== navToken) return; // the user navigated again
await lifecycle.mount(app, slot, contextFor(next));
current = next;
} catch (err) {
if (token !== navToken) return;
report(err, { app: next.name, version: next.version });
renderFallback(slot, next, err);
}
}
The navToken check handles a race: a user clicks Checkout, the remote is slow, and they click Search before it arrives. Without the check, checkout would mount on top of search 3 seconds later. Each navigation takes a number, and any older navigation that finishes late quietly gives up.
The shared frame
The shell renders the header, navigation, account menu, footer, and a toast area, and they stay mounted across app switches, so moving from search to checkout never blanks the header. The cart badge in the header is a small piece of state the shell owns but checkout updates (next chapters show how). Keep the frame thin: if the header grows a search box with autocomplete, that box should be a widget exposed by the search team and mounted by the shell, not search logic copied into the shell.
Check your understanding · Exercise 4
Which of these belong in the shell? Select all that apply.
Cross-app communication: keep it small
Apps need to talk: the product page adds an item and the cart badge in the header must go from 2 to 3, the seller app needs to know who is logged in, and search wants to send the user to checkout with a coupon applied. Every channel between apps is a contract that two teams must keep stable, so the goal is the fewest, narrowest channels that do the job.
The naive design is a shared global store: put the cart, the user, the search filters, and the checkout state in one Redux store that every app can read and write. Within a quarter, checkout reads state.search.lastQuery for a banner, search writes state.cart.items for a quick-add button, and the store's shape can't change without a meeting of four teams. Worse, every app now depends on the same store library and version. We recreated the monolith, with more network requests.
Decision How do apps share information with each other and with the shell?
What we pick: The URL is the main contract between apps. For live updates we use the shell's small event bus, built on DOM custom events so non-React apps can join, with a handful of documented events. Shared state is limited to what the shell owns: the user, the session, the locale, and feature flags, passed to each app on mount. No global store.
Events as a contract
export type ShellEvents = {
"cart:changed@1": { itemCount: number };
"user:changed@1": { userId: string | null };
"toast:show@1": { kind: "info" | "error"; text: string };
};
export type EventBus = {
emit<K extends keyof ShellEvents>(name: K, detail: ShellEvents[K]): void;
on<K extends keyof ShellEvents>(
name: K,
fn: (detail: ShellEvents[K]) => void,
): () => void; // returns unsubscribe, call it in unmount
last<K extends keyof ShellEvents>(name: K): ShellEvents[K] | undefined;
};
Three rules keep the bus healthy. Events describe facts that already happened (cart:changed), not commands to another team's app (checkout:open-drawer), so the sender doesn't depend on who listens. Payloads are small and stable: the item count, not the whole cart. And each event name carries a version, so the shell can emit both @1 and @2 during a migration.
cart:changed@1, after the server confirms; whoever cares listens. If the POST fails, no event is sent and the product app shows its own error.Auth and session through the shell
Login is the one piece of state every app needs and no app should own. The shell runs the login flow and keeps the session. The best setup keeps the session in an HttpOnly cookie on the same site as the APIs, so apps make calls with credentials: "include" and never touch a token.
When APIs need a bearer token instead, the shell exposes ctx.auth.getToken(), which returns a fresh access token from memory and refreshes it when it is about to expire. Apps never store tokens themselves, so there is one refresh at a time instead of eight apps racing to refresh the same token.
Notice the failure path: when refresh fails, the shell, not the app, decides what happens. It emits user:changed@1 with a null user so every app can clear private data, then sends the user to the login page with a return URL. Each app handling expiry its own way is how a product ends up with three different "session expired" screens.
Check your understanding · Exercise 6
Search wants checkout to start with a coupon applied when the user follows a promo link. What's the best channel? Select all that apply.
Isolation: one broken app must not break the page
Twelve teams share one document, one window, and one cascade. Without walls, a mistake in one app shows up in another team's area, and the wrong team gets paged. There are three kinds of leaks to stop: styles, JavaScript globals, and failures.
CSS: stopping styles from leaking
The classic incident: the seller team adds h2 { margin: 0 } to a reset file. It ships at 2 pm. Every h2 in search, product, and checkout loses its spacing the moment a user who visited the seller area navigates elsewhere, because the seller stylesheet stays in the page after unmount. Nobody on the seller team looks at checkout, so the bug lives for a day.
Decision How do we keep each app's CSS inside its own area?
What we pick: Hashed class names plus prefixes, enforced by a stylelint rule that rejects element and global selectors outside @layer reset, and cascade layers to set precedence. Apps remove their own style tags on unmount, and the contract test checks it. Shadow DOM is reserved for small embedded widgets where it fits; for whole apps with modals and portals it causes more trouble than it saves.
JavaScript globals and polyfills
Apps share one window. Two apps that each define window.analytics, or each register the custom element acme-date-picker, collide: the second customElements.define call throws a NotSupportedError because the name is taken. Polyfills are worse: one app ships an old Promise or fetch polyfill that replaces the browser's version for everyone. The rules: apps never write to window (a lint rule enforces it), never ship polyfills (the shell owns the polyfill set for our browser baseline), and namespace custom element names by app.
Failures: timeouts, fallbacks, and boundaries
A remote can fail in four ways, and each needs a different response. It can be slow (the CDN edge is cold or the network is bad), missing (a 404 because a deploy uploaded files to the wrong path), or it can throw while starting (in bootstrap or mount). Or it can crash later, during a render after a click.
| Failure | Who notices | Shell's response | User sees |
|---|---|---|---|
| Slow load | Shell's timer | Skeleton until 6 s, then fallback; late code is discarded | Skeleton, then "Checkout is taking longer than usual" with Retry |
| 404 or network error | The import promise rejects | Load error: report, offer retry with backoff | Same fallback; header and other apps keep working |
| Throws in bootstrap or mount | Shell's try/catch around the lifecycle call | Mark broken, report with app and version, unmount leftovers | Fallback; retry is hidden because the same code fails again |
| Crashes after mount | The app's own root error handler | App renders its own error view and calls ctx.reportError | That app's error view; everything else intact |
The last row has a trap. The shell's React error boundary only catches errors in the shell's own React tree. A remote calls createRoot itself, so its render errors happen in a separate root that the shell's boundary never sees. The contract therefore requires each app to bring its own boundary.
Our mount helper does it for React apps: it wraps the app in an error boundary and passes React 19's onUncaughtError root option, which reports anything that escaped. Boundaries only catch errors thrown while React renders, not in event handlers, timers, or promises. For those, the shell listens for error and unhandledrejection on window and attributes each error to an app by the script URL in its stack trace.
That attribution needs one setting. Our remotes load from cdn.acme.com, a different origin from the page, and the browser hides the details of errors from cross-origin scripts, reporting only the message "Script error". The CDN must send CORS headers, and the loader must request scripts in CORS mode (the crossorigin attribute; webpack's output.crossOriginLoading option sets it on chunks). The error-tracking lesson covers this in depth.
Timeouts have a catch too: a dynamic import() can't be cancelled. When the 6-second timer wins the race, the download keeps going. If the code arrives at second 9, the navigation token check from the shell's router makes the late result do nothing. Pick the timeout per app from real data: about three times its 95th percentile load time on mobile, so a normal slow load isn't cut off but a hung one is.
Try it: make the checkout remote slow, missing, or crash during mount, and watch what the shell does and what the user sees. Then switch off the shell's protections to see why each one exists.
Check your understanding · Exercise 7
The checkout remote throws inside its React render 10 seconds after mounting, when the user clicks Pay. Which statements are true? Select all that apply.
Architecture, data model, and the contract
Now put the pieces together. The design has three groups: what runs in the browser (the shell and the mounted apps), what serves code and configuration (the CDN and the registry), and what each team runs to ship (their pipeline). The boundaries follow ownership: everything the platform team owns is on one side of the contract, everything a product team owns is on the other.
This chapter covers the whole picture in three steps: the components and how data flows between them, the little data the shell keeps and where it lives, and the written contract every app implements.
| Part | Owner | Responsibility | Why the boundary is here |
|---|---|---|---|
| Shell runtime | Platform | Boot, read manifest, top-level routing, run lifecycles, timeouts and fallbacks. | Must be stable and small; changes to it affect every team. |
| Frame (header, nav, toasts) | Platform | Shared layout that stays mounted across app switches. | Users must see one product; no app should be able to blank it. |
| Context services | Platform | Auth and session, event bus, feature flags, locale, error reporter, passed to apps on mount. | One owner per cross-cutting concern avoids eight competing token refreshes. |
| Remote loader | Platform | Module Federation runtime: share scope, loading remote entries, preloading. | Isolates the loading technology; swapping to import maps later touches only this part. |
| Apps (remotes) | Product teams | Everything inside their URL prefix: routes, UI, data fetching, state. | The unit of independent deploy. |
| Registry service | Platform | Stores versions per app, validates routes, serves the manifest, runs canaries. | Turns a deploy into a data change with an audit trail. |
| CDN | Platform | Serves immutable versioned assets and the short-lived manifest. | Teams upload files; nobody edits files in place. |
| Contract package | Platform | Types for mount, the context, and events, plus a test harness. | The compile-time half of the contract that runtime composition lost. |
The shell's own bundle holds the runtime, the frame, and the context services: about 50 KB compressed, plus the shared React and design system it provides to everyone. Nothing product-specific lives in it. A good test is to ask, for any proposed shell feature, "would every app need this?" If only two apps would, it belongs in a shared library they import, not in the shell.
How data flows on a first visit
Follow a user who opens /checkout from a bookmark. The HTML comes from the edge with the manifest already inlined and a preload hint for checkout's remote entry. The shell boots, checks the session, and mounts checkout, which fetches its own data from its own backend-for-frontend; the shell never proxies product data. When the user places an order, checkout writes to its API and emits cart:changed@1; the header listens and updates the badge.
Two arrows deserve attention. First, product data flows directly between each app and its own API, never through the shell, so the shell can't become a bottleneck or a place where teams negotiate data shapes. Second, the only data the shell hands to apps is context: user, flags, locale, and the bus. That context is the whole surface the platform team must keep stable.
Think first
Should the shell have one backend-for-frontend that all apps call, to save round trips?
It is tempting: one call at startup could return the user, the cart count, and the first screen's data for any app. But a shared BFF brings back the coupling we removed: every app's data change becomes a change to a service the platform team owns, and it ships on that service's schedule.
The usual split: the shell has a tiny BFF for shell concerns only (session, user, flags, cart count for the header, the manifest), and each team owns its own BFF for its own screens. The shell can start both requests in parallel so the extra service costs no extra round trip.
Data model: what the shell knows and where it lives
A shell holds little data, and that is the point. Product data belongs to the apps and lives inside them. The shell's model is about the apps themselves: which exist, which version is live, what state each is in, and the small context it shares. Where each piece lives decides what survives a reload, what can be cached, and what one team can break for another.
type AppName = string; // "checkout"
type AppRecord = {
name: AppName;
version: string; // "5.2.0", immutable once published
entry: string; // URL of the remote's mf-manifest.json
routes: string[]; // ["/checkout", "/cart"]
owner: string; // "team-payments", used to route alerts
timeoutMs: number;
fallback?: { kind: "link" | "message"; href?: string };
};
type Manifest = { schema: number; generatedAt: string; apps: Record<AppName, AppRecord> };
type AppStatus =
| "not-loaded" | "loading" | "bootstrapping" | "not-mounted"
| "mounting" | "mounted" | "unmounting" | "load-error" | "broken";
type AppRuntime = { record: AppRecord; status: AppStatus; module?: AppModule; lastError?: string };
type ShellContext = { user: User | null; locale: string; flags: Record<string, boolean> };
| Data | Lives in | Lifetime | Why there |
|---|---|---|---|
| Manifest | Registry, inlined into the HTML, then shell memory | One page load | The page must keep using the versions it booted with; switching mid-session mixes old and new code. |
| App runtime status | Shell memory, a map keyed by app name | Page lifetime | Pure runtime facts; nothing to persist. |
| Current area and sub-route | The URL | Bookmarkable | The URL is the routing contract between shell, apps, and users. |
| User and session | HttpOnly cookie; user profile in shell memory | Session | One owner; apps read it from context, never store it. |
| Feature flags and locale | Shell memory, from the shell BFF | Page load | Read by many apps; evaluated once so all apps agree. |
| Last value of each event | Event bus memory | Page lifetime | Lets an app mounted later read the current cart count. |
| App data (cart, search results, orders) | Inside each app: its server cache and stores | Up to the app | Owned by one team; other apps only see events about it. |
One rule in that table prevents a whole class of bugs: a page keeps the manifest it booted with. If the shell polled the manifest and hot-swapped checkout from 5.2.0 to 5.3.0 while the user was on the payment step, the new code might expect a different state shape or a different API. Instead, a new manifest takes effect on the next full page load.
If an urgent rollback must reach open tabs, the shell can poll a tiny "minimum versions" endpoint every few minutes and, when the running version is below the minimum, swap the app on the next area switch, never mid-flow.
Check your understanding · Exercise 8
Where should these pieces of data live? Select all that apply.
Interface: the contract between shell and apps
With runtime composition, the contract replaces the type checker of a single build, so it must be small, written down, and versioned. It has three parts: the module every app exposes, the context the shell passes in, and the registry API that teams call to publish. All three live in the @acme/shell-contract package, which apps install as a normal build-time dependency.
What every app exposes
export type AppModule = {
contractVersion: 2;
bootstrap?(ctx: BootContext): Promise<void>;
mount(el: HTMLElement, ctx: MountContext): Promise<void>;
unmount(el: HTMLElement): Promise<void>;
};
export type MountContext = {
basePath: string; // "/checkout": the app routes below it
user: User | null;
locale: string;
flags: Readonly<Record<string, boolean>>;
auth: { getToken(): Promise<string> };
events: EventBus;
navigate(to: string): void; // cross-app navigation goes through the shell
reportError(err: unknown, extra?: object): void;
};
Each field earns its place. basePath lets the same app be mounted under a different prefix without code changes. navigate exists so an app never needs to know whether another area is a remote, a legacy page, or an iframe: it asks the shell to go to /account, and the shell decides between a client-side switch and a full page load. contractVersion lets the shell support two contract versions during a migration and refuse remotes that are too old with a clear error.
export function reactApp(App: ComponentType<{ ctx: MountContext }>): AppModule {
const roots = new WeakMap<HTMLElement, Root>();
return {
contractVersion: 2,
async mount(el, ctx) {
const root = createRoot(el, {
onUncaughtError: (error) => ctx.reportError(error, { phase: "render" }),
});
root.render(
<AppErrorBoundary ctx={ctx}><App ctx={ctx} /></AppErrorBoundary>,
);
roots.set(el, root);
},
async unmount(el) {
roots.get(el)?.unmount();
roots.delete(el);
},
};
}
The registry API
Teams never edit the manifest by hand. Their pipeline calls the registry, which validates and records every change. Publishing a version and making it live are separate steps, so a version can be uploaded, smoke-tested, and only then promoted.
POST /apps/checkout/versions
{ "version": "5.3.0", "entry": "https://cdn.acme.com/checkout/5.3.0/mf-manifest.json",
"routes": ["/checkout", "/cart"], "contractVersion": 2,
"shared": { "react": "^19.0.0", "@acme/design-system": "^7.2.0" } }
-> 201 { "version": "5.3.0", "checks": { "routes": "ok", "shared": "ok", "entryReachable": "ok" } }
PUT /apps/checkout/live
{ "rollout": [ { "version": "5.2.0", "weight": 95 }, { "version": "5.3.0", "weight": 5 } ] }
-> 200 { "manifestEtag": "\"m-81f2\"" }
GET /manifest.json
-> 200 Cache-Control: no-cache CDN-Cache-Control: max-age=5 ETag: "m-81f2"
Errors use one shape so pipelines can print them: { "error": "route_conflict", "message": "/cart is owned by team-payments", "field": "routes" }. The registry rejects a version whose shared ranges can't be satisfied by the shell's provided versions, whose entry URL returns anything but 200, or whose routes overlap another app's. Those three checks catch most broken deploys before any user sees them.
Check your understanding · Exercise 9
Why does the contract expose mount(el, ctx) instead of exporting a React component? Select all that apply.
Performance: waterfalls, duplicates, and budgets
Client-side composition has a built-in performance problem: every step waits for the one before. Take a mid-range phone on 4G with 150 ms of round-trip time, opening /checkout cold. The browser fetches the HTML, then the shell's JavaScript, which fetches the manifest, which names checkout's mf-manifest.json, which names the remote entry, which loads the exposed module, which finally fetches the cart.
That is seven sequential steps. At about 150 ms of waiting each, plus download and parse time, the cart data starts loading after more than a second, and the Largest Contentful Paint lands near 2.8 seconds, past our 2.5-second budget.
Flattening the waterfall
- Inline the manifest. The edge renders the HTML with the manifest in a
scripttag of typeapplication/json. That removes one round trip, and the manifest is always consistent with the HTML. - Preload the active app from the server. The edge knows the URL, so it knows checkout will be needed. It adds preload hints for the shell and for checkout's remote entry and first chunk (Module Federation 2.0's per-remote manifest lists them):
<link rel="preload" as="script">for classic scripts like a defaultremoteEntry.js, or<link rel="modulepreload">for builds that output ES modules. The browser fetches them in parallel with the shell instead of after it. - Serve every app from one CDN hostname. Putting the shell, every remote, and the manifest behind
cdn.acme.commeans one DNS lookup and one TLS connection, reused for every file. If a remote must live elsewhere, add<link rel="preconnect">for it. - Start the data early. An app can expose a tiny
loadermodule next to itsappmodule. The shell calls it as soon as the route is known, in parallel with loading the UI code, the same idea as route loaders in modern routers.
With those four changes the chain becomes three steps: the HTML; then the shell and checkout's code in parallel; then the cart request, which the shell starts through checkout's loader as soon as it boots. The same visit paints in about 1.6 seconds in our model. The shell still costs more than a monolith, but the page now waits on three steps in a row instead of seven.
Preloading the likely next app
Switching areas also waits for code. The shell can guess the next area: when the user hovers or focuses the Checkout link for more than about 100 ms, it calls the loader's preload for checkout (fetch the entry and chunks without running them).
After the first screen is idle, it can preload the one or two most common next areas from analytics, for example cart after product. It skips this when the user has asked to save data (navigator.connection.saveData, reported by Chromium browsers), and it never preloads more than two apps, because each costs real bytes on a metered plan. The code-splitting lesson covers the same trade at the level of routes.
Finding and removing duplicate code
Sharing React fixes the biggest duplicate, but each team also bundles its own helpers. A weekly job downloads every live remote's stats file and lists packages that appear in three or more remotes. In a typical audit, a date library, a validation library, and an icon set show up in most apps, each 10 to 30 KB compressed. The fix depends on the package: stateless utilities can stay duplicated if small, become non-singleton shared modules if large, or move into the design system bundle if they are part of the look (icons, for example).
Budgets per team
In a monolith, one team watches the bundle. With twelve teams, nobody does unless the budget is per team and enforced where each team works: in its own pipeline. Here is how we split it.
| Part | Budget | Enforced by |
|---|---|---|
| Shell runtime and frame | 60 KB | Shell pipeline |
| Shared: React, React DOM, router, design system | 150 KB, cached across all areas | Platform review on upgrades |
| Each app, first route | 120 KB | The app's pipeline fails the build above it |
| Each app, any later route chunk | 60 KB | The app's pipeline |
| Dashboard widget | 40 KB | The owning team's pipeline |
Bytes are only the input. The output is measured in the field: Largest Contentful Paint and Interaction to Next Paint per area, tagged with the app and version (see the observability chapter). A team whose area's INP gets worse after a release sees it on their own dashboard the same day.
Check your understanding · Exercise 10
A cold load of /checkout waits on HTML, then the shell, then the manifest, then checkout's entry, then its chunk, then the cart API. Which changes shorten the critical path? Select all that apply.
Deployment: immutable files and the manifest as a switch
Independent deploys are the reason this whole system exists, so the deploy path must be fast, safe, and boring. The naive way is to upload checkout's new build over the old files at cdn.acme.com/checkout/remoteEntry.js.
Three things go wrong. Browsers and CDN edges hold the old file for a while, so users get a mix of old entry and new chunks. A user with checkout open loads a lazy chunk that no longer exists and gets a ChunkLoadError. And rollback means re-uploading the old build and waiting for caches again.
Decision How does a team ship and roll back its app?
What we pick: Immutable versioned files and a manifest. The deploy uploads files, the registry promotes a version, and nothing else changes. This is the same idea as content-hashed assets in any single app, applied one level up.
Cache headers: long for files, short for the switch
Two kinds of files, two opposite cache policies (the http-caching and cdn-caching lessons explain the headers in depth). Versioned assets never change, so they get Cache-Control: public, max-age=31536000, immutable: a year, and the browser doesn't even revalidate on reload. The manifest and the HTML that inlines it are the switch, so they must update fast.
For browsers, the manifest gets Cache-Control: no-cache with an ETag: the browser asks every time but gets a tiny 304 Not Modified when nothing changed. no-cache binds the CDN too, so to let the CDN absorb traffic spikes we add a CDN-only header, CDN-Cache-Control: max-age=5, stale-if-error=86400 (Fastly reads Surrogate-Control instead). The CDN keeps the manifest for 5 seconds, serves the last good copy if the registry is down, and the registry purges it on every promote.
| Resource | Cache-Control | Why |
|---|---|---|
/checkout/5.3.0/*.js, *.css | public, max-age=31536000, immutable | The path changes with every version, so the content at a path never does. |
/manifest.json | no-cache, plus CDN-Cache-Control: max-age=5, stale-if-error=86400 | It is the switch; rollback must reach new page loads within seconds. |
| HTML for app pages | private, no-cache | It inlines the manifest and may contain a user-specific canary choice. |
Canary and rollback
A canary sends a small share of users to the new version first. The registry stores weights per app (95 percent on 5.2.0, 5 percent on 5.3.0) and the edge picks a version per user by hashing a stable id, such as the user id or a first-party cookie, into a bucket from 0 to 99. Hashing keeps each user on the same version across page loads, so nobody bounces between versions.
The team watches error rate and Web Vitals for 5.3.0 against 5.2.0 on the same dashboard; if the new version's error rate is clearly higher, the registry sets its weight to 0 automatically. That is the rollback: one data change, live on the next page load.
Old versions must stay on the CDN for a while after they stop being live. A user who opened checkout on 5.2.0 an hour ago still loads 5.2.0's lazy chunks when they click to the payment step. We keep every version for 30 days after its last promotion, which costs little storage and removes ChunkLoadError from rollouts. If a version must be pulled for security reasons, the minimum-versions endpoint from the architecture chapter's data model makes open tabs switch on their next area change.
Check your understanding · Exercise 11
Checkout 5.3.0 is on a 5 percent canary and its error rate jumps. Which statements describe a good rollback? Select all that apply.
Consistency and testing across teams
Users don't care that checkout and search are built by different teams. If the buttons differ by 2 pixels and the date formats disagree, the product feels broken. In a single build, consistency comes from sharing code by default. Here it must be designed in, and the same goes for testing: nothing checks that the shell and twelve remotes work together unless we build that check.
One design system, shared at runtime
The design system (see the design-systems lesson) is the main tool for consistency: design tokens for color, spacing, and type as CSS custom properties, plus components like Button and Dialog. We share it as a Module Federation singleton so the page has one copy, one theme, and one toast and dialog layer.
The tokens are defined once by the shell on :root; because custom properties inherit, they reach every app, even ones inside a shadow root. When the brand color changes, the shell ships new tokens and every area changes on the next page load, with no app redeploy.
The design system follows strict semver: a minor version may add components but must not change how existing ones look or behave. Breaking changes go in a major, which follows the two-step singleton upgrade from the shared dependencies chapter. Teams that need a component the design system doesn't have build it locally and propose it upstream, so differences stay temporary instead of permanent.
Contract tests between shell and remotes
Every remote's pipeline runs a contract harness from @acme/shell-contract: a fake shell that loads the built remote the way production does, with the shell's real shared versions, and checks the contract. It mounts the app into a slot, checks that something rendered, unmounts it, and checks that the slot is empty, no window listeners were left behind, no global variables were added, and no style tags remain. It also checks that the app only emits events listed in the contract types. A remote that fails can't be published to the registry.
The shell's pipeline runs the same harness the other way round: it loads the currently live version of every remote against the new shell build. If a shell change would break checkout 5.2.0, the shell team finds out before release, not from checkout's on-call.
End-to-end smoke tests per composition
Contract tests check each pair. A few end-to-end tests check the whole composition: the exact set of versions a manifest would put live. When a team publishes, the registry builds a candidate manifest (current live versions plus the new one) and runs about 15 Playwright journeys against staging with that manifest: sign in, search, open a product, add to cart, check out, open the seller dashboard.
Only a green run allows the promote. Keep this suite small and stable; each team's own deep end-to-end tests stay in its own pipeline.
Check your understanding · Exercise 12
The search team's new version passes all its own unit and end-to-end tests, but it leaves a scroll listener on window after unmount. Which test catches it before users do? Select all that apply.
Observability, SSR, and SEO
With twelve teams, "the site has errors" is useless. The first question in any incident is "whose code?", and the shell must answer it automatically. The error-tracking lesson covers the tools; here is what is specific to a shell.
Every error tagged by its owner
- Through the context. Errors reported with
ctx.reportErroralready know their app; the shell addsapp,version, andownertags from the manifest before sending. - Through the stack trace. For errors caught by the global
errorandunhandledrejectionhandlers, the shell reads the script URLs in the stack. Because every file lives under/checkout/5.3.0/, the path says which app and version threw. - Through releases. Each app uploads its source maps under a release name like
checkout@5.3.0, so stack traces map back to that team's source, and the error tracker routes alerts to the owning team. - Through performance data. Web Vitals are reported with the area that was mounted. For slow interactions, the Long Animation Frames API (
PerformanceLongAnimationFrameTiming, in Chromium browsers) lists the script URLs that ran, which again map to an app and version.
The shell also reports its own events: manifest fetch failures, timeouts per app, load errors, broken apps, and how long each mount took. A dashboard with "time to mounted" per app at the 75th percentile makes slow remotes visible before users complain.
SSR and SEO with client-side composition
A client-composed page sends an almost empty HTML document; the content appears only after the shell, the manifest, and the remote run. Google can render JavaScript, but rendering may happen later than crawling, other crawlers and link previews often don't run JavaScript at all, and Largest Contentful Paint suffers for users anyway. For logged-in areas like checkout and seller tools, this doesn't matter. For product and search pages, it does.
So public pages use server or edge composition. The edge fetches each fragment's server-rendered HTML (product details from the product team, reviews from the reviews team) with a per-fragment timeout and streams the page; each fragment then loads its JavaScript to become interactive, sharing React through the same runtime as the logged-in shell.
If a fragment misses its timeout, the edge sends a placeholder and the fragment renders on the client. This is more work for the platform team, which is why we limit it to the pages where search traffic and first paint matter most.
Check your understanding · Exercise 13
An error spike appears after 3 pm. Which signals let the shell route it to the right team automatically? Select all that apply.
Running it in the interview
This question rewards judgment more than mechanics. An interviewer who asks it usually works somewhere that has a shell, or regrets one. They want to hear that you know the cost, can pick a composition method for a reason, and have thought about the day a remote fails. Here is one way to spend 60 minutes.
| Minutes | What to do | What to draw or say |
|---|---|---|
| 0 to 7 | Scope: teams, deploy frequency, frameworks, SEO, trust. | "With 12 teams shipping daily, independence is worth paying for. If it were 3 teams, I'd propose a modular monorepo." |
| 7 to 17 | Composition choice. | The three-times picture: build, edge, client. Pick client-side runtime loading for the app and edge composition for public pages. |
| 17 to 30 | The shell: routing, manifest, lifecycle, contract. | Architecture boxes, the manifest JSON, the mount(el, ctx) contract, the lifecycle states. |
| 30 to 40 | Shared dependencies and communication. | Singleton React with version ranges; URL first, then a small event bus; auth owned by the shell. |
| 40 to 50 | Failure and isolation. | The four failure modes and the shell's response to each. CSS rules. Error boundaries per app. |
| 50 to 60 | Deploys, performance, observability. | Immutable paths plus manifest switch; waterfall and how to flatten it; errors tagged by app. |
Draw the architecture diagram early and keep adding to it. The manifest is the most important box on the board: point at it whenever you talk about deploys, canaries, rollbacks, routing ownership, or version checks. Interviewers remember a candidate who explains five features with one mechanism.
Follow-up questions to expect
Think first
What if the manifest service is down?
The manifest is inlined into the HTML by the edge, which caches the last good manifest and serves it stale if the registry fails (the stale-if-error directive from the deployment chapter). The shell also ships with a baked-in last-known-good manifest from its own build as a final fallback. Users get slightly older versions, never a blank page.
Think first
The dashboard shows four widgets from four teams. How does that change the shell?
The contract stays mount(el, ctx), but a remote can expose several modules, like ./app and ./summary-widget. The shell renders a layout of slots and mounts them in parallel, each with its own skeleton of a fixed size (so the layout doesn't shift), its own timeout, and its own fallback. Widgets get a narrower context: no navigate except through links, and their own query parameter at most.
single-spa has a name for this: a parcel, a piece of UI with the same lifecycle functions that an app mounts by hand instead of the router mounting it by URL. Its docs suggest parcels mainly when the pieces use different frameworks. We want the same shape for a different reason: each widget ships with its own team's deploy, not with the dashboard's.
Think first
How would you move from today's monolith to this design without a big-bang rewrite?
Make the monolith the first remote. The shell starts with one app that owns /, so nothing changes for users. Then teams carve out one area at a time: checkout becomes its own remote owning /checkout, and the monolith stops serving that prefix. Each step is a small, reversible manifest change. This is the strangler pattern, and it is how the Angular area will eventually be replaced too.
Check your understanding · Exercise 14
The interviewer asks: "Why not just make every app an iframe? Then nothing can leak." Which points belong in a strong answer? Select all that apply.
Summary
- Micro-frontends buy independent deploys and pay for them with runtime complexity, duplicate-code risk, and a platform team. With a few teams on one release train, a modular monorepo is the better answer.
- Split vertically by area and URL prefix, one team per area. The shell owns the first path segment; each app owns everything below it.
- Mix composition methods by page type: client-side runtime loading for the logged-in app, edge composition for pages that need SEO and fast first paint, iframes only where trust requires a security wall.
- The contract is
mount(el, ctx)andunmount(el), framework-neutral, versioned, and checked by a test harness, because no single build checks it any more. - Share stateful libraries like React and the design system as singletons with semver ranges; upgrade them in two steps so no remote blocks the fleet.
- Communicate through the URL first, a small typed event bus second, and shared context owned by the shell (user, session, flags, locale). Never a global store.
- Expect partial failure: per-app timeouts, fallbacks, separate error boundaries in every app root, and errors tagged with app, version, and owner.
- Deploy immutable versioned files and make the manifest the switch: canary by weight, rollback in seconds,
immutablecaching for files,no-cachefor the manifest. - Flatten the loading waterfall: inline the manifest, preload the active app's code, start its data early, and give every team its own enforced budget.
Check your answer
Tick what your own design covered. Pick the level you're interviewing for: each level includes the ones before it.
References
- Micro Frontends (martinfowler.com) Cam Jackson's overview of integration approaches, with a worked example.
- single-spa: building applications The bootstrap, mount, unmount, and unload lifecycle and lifecycle props.
- Module Federation (webpack docs) The original concepts: containers, remotes, and shared modules.
- ModuleFederationPlugin options (webpack docs) singleton, requiredVersion, strictVersion, and eager, with how a version is picked.
- Module Federation: shared configuration singleton, requiredVersion, and how versions are chosen.
- Module Federation runtime API createInstance, loadRemote, and registerRemotes for loading remotes from a manifest.
- Import maps (MDN) The imports and scopes keys, and how bare specifiers resolve.
- Cascade layers: @layer (MDN) Layer order and why unlayered styles win.
- createRoot (React docs) Root options including onUncaughtError, used to report crashes per app.
- Prevent unnecessary network requests with the HTTP Cache (web.dev) Long-lived immutable assets versus revalidated entry files.
- single-spa: parcels Framework-neutral pieces mounted by hand, with the same lifecycle as apps.
- Micro Frontends: from Fragments to Renderers (Zalando Engineering) Project Mosaic's fragments and Tailor, and why Zalando moved to a shared framework.
- Building the Future of Our Desktop Apps (Spotify Engineering) The older desktop client's iframe per page, and the move to one codebase.
- Podium documentation Server-side composition with podlets and layouts, from FINN.no.
Concepts behind it
- Micro-frontendsSplitting one web app into parts that separate teams build and deploy on their own, then combining them at build time, on the server, or in the browser: how each approach works, what it costs in bytes and complexity, and why most teams should not do it.
- MonoreposOne repository for many apps and shared packages, plus what makes it scale: workspaces that link packages, task runners that build only what changed and cache the rest, folder owners, and a rule for versioning internal code.
- Design systemsA shared set of design tokens, accessible components, patterns, docs, and rules that many teams build on, so a company ships one consistent product instead of many slightly different ones, and can change its look in one place.
- Client-side routingHow single-page apps change screens without reloading: matching URLs to nested layouts, loading each route's code and data in parallel, guarding private pages, and restoring scroll and focus.
- Code splitting and bundlingBreaking one big JavaScript bundle into smaller pieces that load only when needed, because every kilobyte of JavaScript costs download, parse, and run time on the user's device.
- Web components and shadow DOMThe browser's own component model: custom HTML tags with their own lifecycle, a shadow tree that keeps their markup and styles private, and slots for the page's content, so one component works in React, Vue, Angular, or plain HTML.
- CDN cachingKeeping shared copies of your responses on servers near every user, so most requests never travel to your origin: the biggest lever for global speed and for surviving traffic spikes.
- Error trackingCatching the errors that happen in users' browsers, decoding minified stacks, grouping thousands of reports into a few issues, and alerting when a release makes things worse: how you learn the app is broken before support does.