The interview trap here is enthusiasm. Micro-frontends are an organizational solution, not a technical one — they buy team autonomy and you pay in bundle duplication, version skew, and ops complexity. The Lead move is to know when NOT to, and to reach for a monorepo first.
Micro-frontends are “an architectural style where independently deliverable frontend applications are composed into a greater whole.” [Fowler] But the reason to adopt them is almost never technical. The honest framing: you split the frontend so you can split the teams — independent deploys, independent stacks, end-to-end ownership. If you don't have a team-scaling problem, you don't have a micro-frontend problem.
The three benefits Fowler names — and they're all about people, not performance: [Fowler]
“Micro-frontends decouple teams, not code. I adopt them when org coordination is the bottleneck — not to make the app faster. They make it slower.”
“Micro-frontend” is a goal; the interesting question is where the composition happens. The core picture: three independently-built, independently-deployed apps (each its own team, repo, pipeline) are stitched into one page the user sees as a single product. Watch the shell load each slice on its own:
Three separate apps → composed into one page at runtime. Each slot fills independently, on its own team's deploy cadence. That independence is the whole point — and the source of every cost.
Fowler lists five approaches; know them as a spectrum from build-time (coupled) to runtime (decoupled): [Fowler]
| Approach | How | Trade-off |
|---|---|---|
| Build-time (npm packages) | each MFE is a published package the shell imports | Simple, but re-couples deploys — shell must rebuild/redeploy to ship any change. Defeats the purpose. |
| Server-side composition | edge/SSR assembles fragments (SSI, Tailor) | Great for SEO/first paint; harder for rich client interactivity. |
| Iframes | each MFE in its own iframe | Bulletproof isolation (CSS/JS) but awful for routing, deep-linking, shared state, height. |
| Run-time via JavaScript | shell fetches & mounts remote bundles at runtime | Most flexible & common. single-spa orchestrates; Module Federation shares deps. |
| Web Components / import maps | standards-based custom elements; import maps map bare specifiers to URLs | Framework-neutral, no bundler lock-in; less tooling/DX than Module Federation. |
Module Federation (Webpack 5 / now Rspack & the standalone @module-federation/* runtime) is the de-facto runtime tool: a host loads code from a remote at runtime and they can share dependencies (one React instance instead of three). single-spa is the orchestrator/router; import maps are the native-browser primitive. Know all three names. [module-federation.io]
// host webpack.config — load "cart" remote at runtime, share ONE React
new ModuleFederationPlugin({
name: 'shell',
remotes: { cart: 'cart@https://cart.example.com/remoteEntry.js' },
shared: {
react: { singleton: true, requiredVersion: '^18.2.0' },
'react-dom': { singleton: true, requiredVersion: '^18.2.0' },
},
});
// singleton:true ⇒ load ONE shared copy. The version negotiation here
// is exactly where "version skew" lives (see §3).
“Pick by where composition happens. Build-time re-couples your deploys; iframes over-isolate; runtime JS — Module Federation or single-spa — is the sweet spot, and it's where the hard problems live.”
Anyone can recite the benefits. A Lead names the bill. Five real costs, each with the one-liner you'd say in the room:
“Independently-built bundles can cause duplication of common dependencies, increasing the bytes users download.” [Fowler] Three MFEs = three lodash/React unless you share — and sharing reintroduces coupling.
The instant you force every team onto the exact same version of a shared lib, you've killed independent deploys. Let them drift and singleton:true negotiation can load an incompatible React → runtime crash. [LogRocket]
Remotes load at runtime, so TypeScript types are lost at the boundary — a breaking change in a shared component only blows up after deploy, in the consumer. [AngularArchitects] A bug in one remote can take down the shell (fate sharing).
Passing the auth token / cart between a host and a remote “can quickly spiral into a mess.” [LogRocket] Keeping fonts, spacing, and a11y consistent across independently-shipped teams needs a hard-enforced design system.
“The MFE bill: duplicated bytes, version skew, lost types, fate sharing, and ops overhead. I only pay it when team autonomy is worth more than all five — and at most shops, it isn't.”
This is the favourite follow-up: “Shell needs React 18.2, a remote was built against 18.3 — what actually happens?” Module Federation maintains a shared scope: a runtime registry where every container publishes the versions it brought and consumers resolve the best match. The outcome depends entirely on how you flagged that dependency in shared. [MF docs]
| Config | What happens with a version mismatch |
|---|---|
| default (not singleton) | Semver match: if a compatible version exists in the scope, the highest compatible one is shared. If incompatible, each app falls back to its own copy → two versions load side-by-side. Safe, but duplicated bytes. [AngularArchitects] |
singleton: true | Exactly one copy loads — the highest version known at init, regardless of compatibility. If versions are incompatible you still get one copy + a console warning, and the lower-version consumer runs against the higher React (may break at runtime). Required for React/React-DOM — two copies = broken hooks/context. |
strictVersion: true | Turns that warning into a hard runtime error — fail fast instead of silently running on an incompatible singleton. |
requiredVersion: '>=18 <20' | Widens the accepted range so a higher major (e.g. 19) is treated as compatible instead of triggering a fallback/duplicate. Your lever to permit drift deliberately. |
eager: true | Puts the shared dep in the initial chunk (synchronous) instead of an async negotiated load — sometimes needed for the host, but it bloats the entry bundle. Prefer lazy. |
// The mental model: singleton = "one copy, highest wins, warn on mismatch".
shared: {
react: { singleton: true, strictVersion: true, requiredVersion: '^18.2.0' },
}
// → exactly one React. If a remote shows up with an incompatible 17.x,
// strictVersion makes it CRASH LOUDLY at load, not corrupt hooks silently.
strictVersion). That's why shared deps are governed, not negotiated: a single source of truth for React/design-system versions (renovate, a shared config) so the runtime never has to guess. For stateful singletons (React, the router, a shared store) you must dedupe; for stateless utilities (lodash, date-fns) duplication is merely a size cost.
The drill: read the config line, say the outcome out loud (how many copies? whose version wins? any warning/crash?), then click to check. Assume each party's requiredVersion is the default caret ^ of the version it declares (so same major = compatible) unless noted. Left border: clean · duplication / warning · crash.
{17.0, 18.0, 18.2}. shell needs ^17 → no 18.x fits → falls back to its own 17.0. r1 (^18.0) and r2 (^18.2) both take the highest 18.x → 18.2.
⚠️ Two copies: 17.0 (shell alone) + 18.2 (r1, r2). No crash.
A party on a different major just gets its own copy — graceful, but duplicated bytes.
18.2.0 → share 18.2.0. r2 accepts only 18.3.0 → uses 18.3.0.
⚠️ Two copies (18.2.0 + 18.3.0) — even though all are React 18.
Over-strict requiredVersion pins defeat sharing within the same major. Use caret ranges, not exact pins.
^18.x range.
✅ One shared copy: 18.5. No warning.
Within a major, singleton silently picks the highest minor — the common happy path.
^18 → 19.0 doesn't satisfy → warning; r1 renders against React 19.
⚠️ One copy: 19.0 + a console warning for r1 (skew risk).
The host being on a higher major drags every remote up — and warns the ones expecting the lower major.
^18 → unsatisfied. Without strictVersion this is a warning; with it, the unsatisfied singleton throws.
❌ Hard runtime error at load → white screen.
strictVersion trades a silent-skew warning for a loud crash — fail fast instead of running on the wrong major.
<20, so 19.0 satisfies shell too → no mismatch.
✅ One shared copy: 19.0. No warning.
Widening requiredVersion is how you consent to a higher major and silence the skew warning deliberately.
>=18.9; but a singleton can't be swapped once live → r1 gets the existing 18.0, which is below its floor → warning; r1 can't upgrade it.
⚠️ r1 forced onto the already-live 18.0 (below what it required) + warning.
Timing: a late remote resolves against the already-instantiated singleton — even a higher minor can't upgrade it, and the latecomer can be pushed below its own required version.
^20; nothing in scope satisfies it → r2 loads its own React 20 on demand.
⚠️ Two copies, the second paid lazily on click: 18.4 + 20.0. No crash.
Non-singleton late remotes just bring their own copy — graceful, but the duplication arrives at click time, not page load.
“Default federation loads the highest compatible version and falls back to a private copy when they clash. singleton:true forces one copy — highest wins, warning on mismatch — and strictVersion turns that warning into a crash. React must be a singleton; for the rest I govern versions so the runtime never guesses.”
The golden rule mirrors microservices: prefer the loosest coupling that does the job. The more two MFEs know about each other, the less independent they actually are. The hierarchy, loosest-to-tightest: [Thoughtworks]
City, dates, filters in the URL (Lesson 13). Any MFE can read it; nothing imports anything. Shareable + back-button-safe. The most decoupled “shared state” there is.
Host owns state, passes data down and callbacks up (e.g. catalog gets an onAddToCart). Typed, traceable, easy to test. Best default for host-orchestrated composition.
Native window.dispatchEvent(new CustomEvent('cart:add',{detail})) + listeners, or a tiny event bus. MFEs never import each other. Trade-off: implicit, untyped contract — can rot into spaghetti without discipline.
One federated singleton store (Zustand/Redux) all MFEs read/dispatch to. Structured, but couples everyone to a shared schema — the “shared-state mess” from §3. Reserve for genuinely global state (auth/cart).
// Decoupled sibling→sibling: search MFE fires, cart MFE listens. No import.
window.dispatchEvent(new CustomEvent('app:add-to-trip', { detail: { hotelId } }));
// cart MFE, mounted independently:
window.addEventListener('app:add-to-trip', e => addHotel(e.detail.hotelId));
detail's shape and silently break every consumer (the lost-types problem again). The fix: a shared, versioned contracts package (event names + payload types) in the monorepo, imported by both sides — so even an event bus is type-checked. And never reach into another MFE's internal DOM/state; only talk through the published contract.
“I communicate by the loosest channel that works: URL for navigational state, props/callbacks for host-to-child, custom events for decoupled siblings, a shared store only for truly global state like auth. And I type every event with a shared contracts package — an untyped event bus is just distributed spaghetti.”
Get these straight and the rest is obvious:
remoteEntry.js — the entry file (a "pointer"). A tiny JS file the remote publishes at a fixed, un-hashed URL (e.g. cart.example.com/remoteEntry.js). The host runs it to discover the remote's exposed modules and, crucially, which content-hashed chunk files hold the real code (e.g. cart.8f3a9c.js). It does not contain your components — it points at the chunks that do.mf-manifest.json — the manifest. A separate JSON file that Module Federation 2.0 also emits, describing the remote (its exposes, shared, remoteEntry, chunks). It's metadata the runtime/tooling reads. [MF runtime]They're not the same thing — so I'll stop saying "manifest" loosely and just say entry file. The mental model that makes everything click: one small mutable pointer at a stable URL, pointing at many large immutable chunks whose names change every build.
The URL is a constant in the host's config, compiled in. Simple, but to change a URL, support a new env, or roll a remote back you must rebuild & redeploy the host.
The host reads remote URLs from runtime config and loads modules on demand with loadRemote. Per-env URLs and version swaps with no host rebuild — and it's what makes rollback a config flip.
// STATIC — URL is a compile-time constant in the host's build config:
remotes: { cart: 'cart@https://cart.example.com/remoteEntry.js' }
// DYNAMIC — URL comes from runtime config, loaded on demand:
import { init, loadRemote } from '@module-federation/enhanced/runtime';
const cfg = await fetch('/config.json').then(r => r.json()); // {cartUrl: '…/v1.2.3/remoteEntry.js'}
init({
name: 'shell',
remotes: [{ name: 'cart', entry: cfg.cartUrl }], // ← decided at RUNTIME, not build
});
const { addToCart } = await loadRemote('cart/api'); // lazy-load an exposed module
Exactly the right question. With a static remote the host always fetches the same URL: …/remoteEntry.js. When the remote team redeploys, they overwrite that file in place — same URL, new contents that now point at the new hashed chunks. So:
So the resolution is a deliberate split: the entry file is the one thing you must serve un-cached (it's tiny — revalidating it on every load is cheap), while the heavy chunks it points to are cached forever, safely, because their filenames change when their content changes. Play it through both ways:
After a deploy the CDN now serves cart.b2d4e1.js (new hash) and the old chunk is purged. The only difference between a clean update and a white screen is whether the host got the fresh entry pointer or a cached one.
remoteEntry.js with Cache-Control: no-cache / short TTL / must-revalidate (or cache-bust with ?t=<timestamp>) so the host always revalidates and gets the fresh pointer.Cache-Control: immutable, max-age=31536000 — cache forever, safely. (Entry fresh, chunks immutable = the whole trick.)ChunkLoadError mid-rollout./v1.2.3/remoteEntry.js) + an atomic swap to kill the overwrite-mid-deploy race.# The one cache rule that prevents the outage:
remoteEntry.js → Cache-Control: no-cache # tiny pointer — always revalidate
cart.8f3a9c.js → Cache-Control: immutable, max-age=31536000 # hashed → cache forever
{ "cart": "v1.2.3" } served no-cache; every remoteEntry.js then lives at an immutable versioned URL and is cached forever. You've shrunk the uncacheable surface from "the whole entry file, every remote" down to "one tiny JSON line." Rollback = change that line to v1.2.2.
no-cache already updates correctly, why do we need dynamic?”Because they fix different axes. no-cache answers “give me the freshest contents of a URL I already know.” Dynamic answers “which URL, which version, and whether to load at all — decided at runtime.” Static + no-cache can't touch that second axis: it only ever gives you always-latest, and “always-latest with no way to pin or roll back” is a liability on a revenue funnel.
| You need… | Static + no-cache | Dynamic |
|---|---|---|
| Latest content of a known remote | ✅ yes | ✅ yes |
| Pin a version / roll back with no host redeploy | ❌ version is compiled in | ✅ flip a registry line |
| Different URLs per env / region / tenant | ❌ N host builds | ✅ one artifact, runtime URL |
| Add / remove a remote without touching the host | ❌ edit + redeploy host | ✅ edit the registry |
| Conditional / lazy / A-B load (flag, route, locale) | ❌ fixed compiled list | ✅ decide at runtime |
| Atomic, race-free rollout | ❌ overwrite-in-place window | ✅ publish vN, then flip the pointer |
So static + no-cache is perfectly fine for a small, fixed set of always-latest remotes. The moment you need version control, rollback, per-env URLs, or conditional loading, that's the job dynamic does — and no-cache was never the tool for any of it.
config.json registryIn the dynamic snippet the host fetched /config.json. That's the host-level registry: a tiny JSON mapping each logical remote → the exact versioned entry URL to load right now. Editing it is how you release or roll back.
// config.json — the host's version registry (a "pointer to pointers")
{
"cart": "https://cart.example.com/v1.2.3/remoteEntry.js",
"search": "https://search.example.com/v4.0.1/remoteEntry.js",
"account": "https://account.example.com/v2.7.0/remoteEntry.js"
}
// release = bump "v1.2.3" → "v1.2.4"; rollback = set it back to "v1.2.2"
Don't mix the three “pointer-ish” files — they sit at two levels:
config.json — host-level, one file: name → which version. (The native-ESM equivalent is an import map.)remoteEntry.js — per remote: exposed modules → chunks.mf-manifest.json — per remote: build-generated metadata.Where it lives: owned by the platform / host layer (not any single remote team — it's what decides which versions compose together). Common homes: a static file on the shell's own CDN, an edge KV store (Cloudflare KV, etc.) for instant global updates, or a small config / feature-flag service so version selection can even be per-user / per-region / A-B. The platform pipeline writes to it.
no-cache file. Everything below it is immutable: each remoteEntry.js now sits at a versioned URL (contents never change) and chunks are content-hashed. So you've moved the uncacheable surface from “every remote's entry, overwritten in place” down to one tiny JSON line read at startup. Two caveats to name: it's on the critical path (the host can't load any remote until it resolves — keep it tiny, edge-cache it, or inline it into the initial HTML to skip the round-trip), and it's now a single source of truth — needs a last-known-good fallback and tight access control, because one edited line repoints production traffic.
“remoteEntry.js is a pointer at a stable URL — it's the one file I never cache, while the content-hashed chunks I cache forever. If I cache the pointer, the host is frozen on the old build — or white-screens with a ChunkLoadError if the old chunks were purged. So: entry no-cache, chunks immutable, keep old chunks through the rollout, and drive versions from a tiny config.json registry so rollback is a one-line config flip — never a host redeploy. Static + no-cache only ever gives me ‘always latest’; the registry gives me version control.”
It's in the bank verbatim, so have the answer ready. Say no to MFEs when:
And the pivot that scores points: “before micro-frontends, I'd reach for a monorepo” — you get shared code, atomic cross-cutting changes, and team boundaries without the runtime integration tax. That's §5.
A monorepo is many projects in one repo with shared tooling; a polyrepo is one repo per project. Crucial distinction the panel wants: monorepo is a build/source-organization choice; micro-frontend is a runtime-composition choice. They're orthogonal — you can have a monolith in a monorepo, or MFEs across polyrepos. Conflating them is a common junior tell.
| Monorepo | Polyrepo | |
|---|---|---|
| Code sharing | Trivial — import a local package | Hard — publish/version npm packages |
| Cross-cutting change | Atomic — one PR spans app + lib | Multi-repo dance, version coordination |
| Team independence | Boundaries enforced by tooling | Hard repo boundaries by default |
| CI cost | Needs affected/caching or it explodes | Naturally scoped per repo |
The reason monorepos scale is the project graph + “affected” builds + caching. Nx/Turborepo diff Git, walk the dependency graph to find only the projects touched (and their dependents), and rebuild/test only those — cutting CI by up to 90%. Task inputs are hashed (source + config + env) into a cache key; identical inputs replay a cached result, and remote cache shares those hits across the whole team/CI. [Nx]
Caching + task pipelines with minimal config (turbo.json). Rewritten Go→Rust for speed. Lighter: no code-gen, no graph viz, no boundary lint. Great default for a JS/TS app.
Project-graph viz, generators, module-boundary enforcement (lint rule: this lib may not import that one), distributed task execution, remote cache. Steeper curve; pays off at scale. [Nx]
That module-boundary enforcement is the killer Lead point: in a monorepo you can codify architecture as a lint rule — tag libs (scope:checkout, type:ui) and fail CI if checkout imports admin, or a UI lib imports a data lib. You get team boundaries at build time, no runtime federation needed. [Nx]
“Monorepo and micro-frontend aren't the same axis: one is build-time source layout, the other is runtime composition. I'd get 80% of the ‘independent teams’ win from a monorepo with affected-builds and boundary lint — and only add MFEs when teams truly need independent deploys.”
A classic Lead scenario: “Add a ‘Book with points’ promo that spans search, the property page, the cart, and the user wallet — how do you ship it across micro-frontends owned by four teams?” The senior move is to challenge the premise first, then show the mechanics.
contracts package.“A feature touching every micro-frontend usually means my boundaries are wrong. If it's genuine: the host orchestrates through explicit, typed contracts, the cross-domain logic lives on a BFF not in browser events, and because remotes deploy independently I gate the rollout behind a flag so nothing half-ships.”
This is exactly the rare shop where MFEs can be justified — huge org, many product teams (search, property, checkout, account, B2B). But the SEO/performance constraints push back hard: duplicated bundles hurt LCP, and runtime federation complicates SSR/streaming (Lessons 09–10). So the defensible Lead position is nuanced, not binary:
shared config) so version skew can't silently break a remote in production.Concept: MFEs decouple teams' deploys via runtime composition. Trade-off: bundle duplication, version skew, lost types, fate sharing, ops cost. Anchor: “At platform scale I'd run a monorepo backbone with boundary lint + a shared design system, and carve out MFEs only where a team genuinely needs an independent deploy cadence — SSR-composed, to protect SEO.” Impact: teams move independently without a bundle-size or skew regression on a revenue-critical, SEO-driven funnel. Invite: “Where would you draw the MFE seam for a booking flow — and would you accept the duplicate React?”
Short, confident answers to the adjacent questions that often follow. Each is one trade-off, not an essay.
| Question | The crisp answer |
|---|---|
| Routing across MFEs? | Shell owns the top-level routes and delegates a path prefix to each remote (which owns its sub-routes). single-spa's “activity functions” decide which app is active for a URL. One router of record; no two MFEs claiming the same path. |
| CSS isolation? | Don't rely on global CSS. Use CSS Modules / scoped or hashed class names, Shadow DOM (web-component MFEs), and a shared design-token layer for consistency. Global resets are the #1 cross-MFE style bug. |
| Error isolation / one remote crashes? | Wrap each remote in an error boundary so a crash degrades to a fallback instead of white-screening the shell — the direct mitigation for fate sharing. Same for a remote that fails to load: retry + fallback UI. |
| Sharing auth/session? | Host owns auth; pass the token down (props/context) or via a single shared auth store. Remotes must not each re-implement login or store their own token — one source of truth (ties Lesson 18). |
| SSR with Module Federation? | Harder — federation is client-runtime-first. Options: server-side composition of fragments, or SSR each MFE and stitch at the edge. This is exactly why SEO-critical apps lean server-side, not client federation. |
| Versioning / rollback a remote? | Ship a versioned remoteEntry (immutable URL per build); the host pins or points at a channel. Roll back by repointing — no shell redeploy. Canary + flags for risky changes. |
| Testing across MFEs? | Each MFE: unit/integration in isolation. The seams: contract tests on the published events/props, plus a thin e2e on the composed shell (Lesson 21). You can't rely on one giant integration suite. |
| Preventing two Reacts? | singleton:true on react + react-dom (§4). Two copies = broken hooks/context. For utilities, duplication is just a size cost, not a correctness bug. |
1. What's the single best one-line frame for micro-frontends?
2. Which integration approach re-couples your deploys and largely defeats the purpose?
3. In Module Federation, what is shared: { react: { singleton: true } } doing — and what risk does it introduce?
4. Why is a breaking change in a shared federated component especially dangerous?
5. Name three real costs of micro-frontends you'd raise before adopting them.
6. When would you NOT use micro-frontends?
7. A teammate says “let's go micro-frontends so we can share code between teams.” Best Lead pushback?
8. Monorepo vs micro-frontend — the precise distinction?
9. What makes Nx/Turborepo monorepos scale instead of melting CI?
10. What's Nx's module-boundary enforcement and why does a Lead care?
11. For the platform specifically (SEO-critical, high-traffic, many teams), what's the defensible architecture?
12. Shell needs React ^18.2, a remote was built with 18.3, both marked singleton:true. What happens?
13. Without singleton, a host and remote need incompatible major versions of a lib. Default behavior?
14. You want a singleton React mismatch to fail loudly at load instead of silently running on the wrong version. Which flag?
15. Two sibling MFEs must communicate. What's the Lead's default, and the key safeguard?
16. Asked to ship a feature that touches four MFEs owned by four teams. Strongest opening move?
17. A remote deploys a new version. The host's remoteEntry.js was cached by the CDN, and the deploy purged the old chunks. What do returning users see?
18. What's the correct caching setup to make MFE deploys seamless?
19. How do you change a remote's version (or roll it back) without redeploying the host?
20. If serving remoteEntry.js with no-cache already delivers updates, why use dynamic remotes at all?
21. What is the config.json a dynamic host fetches, and should it be cached?
0 / 21 answered