FE Lead prep · ref 14 · §2.9 §2.10 · lead with the COST, not the hype · "have a strong opinion"
The one frame to memorize
Micro-frontends = an ORGANIZATIONAL solution, not a technical one. You split the frontend to split the teams (independent deploys). Small team → monolith is 10× more efficient. They make the app slower, not faster.
orthogonalMonorepo = build-time source layout. Micro-frontend = runtime composition. Different axes — you can have either without the other. Conflating them is the junior tell.
Why / Why-not MFE
Adopt when
Many teams (~50+ eng) — deploy coordination is the bottleneck
Need independent deploys + end-to-end ownership
Incremental migration off a legacy framework, slice by slice
Don't when
Small/medium team — monolith ships faster
Performance is top priority (MFEs add bytes)
Tightly-coupled, highly-shared UI/state
No CI/CD + design-system maturity → amplifies chaos
3 benefits (all about people): independent deploy · autonomous teams · incremental upgrade. “Decouple teams, not code.”
Integration spectrum (where composition happens)
Approach
Note
Build-time (npm pkg)
❌ re-couples deploys — shell must rebuild to ship a remote. Defeats purpose.
Server-side composition
edge/SSR assembles fragments — best for SEO/first paint
Iframes
bulletproof isolation, awful routing/state/height
Runtime JS ⭐
most common. single-spa=orchestrator · Module Federation=shares deps
Web Components / import maps
framework-neutral, native; less tooling/DX
// Module Federation: host loads remote at runtime, shares ONE React
remotes: { cart: 'cart@https://cart.example.com/remoteEntry.js' },
shared: { react: { singleton: true, requiredVersion: '^18.2.0' } }
// singleton:true ⇒ one copy (no dup-React/hook bugs). The version// negotiation here IS where "version skew" can crash a remote @ runtime.
Fowler's core tension: "tension between letting teams compile independently (autonomy) and sharing common dependencies." Every MFE call is a point on that line.
Two versions of one lib — how MF resolves it
Config
On version mismatch
default
shares highest COMPATIBLE; incompatible → each falls back to own copy (2 versions side-by-side, dup bytes)
singleton:true
one copy, HIGHEST wins regardless of compat + console warning on mismatch. Required for React (2 copies = broken hooks)
strictVersion:true
warning → hard runtime error (fail fast)
requiredVersion
widen accepted range (e.g. '>=18 <20') to permit drift on purpose
eager:true
load in initial chunk (sync) vs async-negotiated; bloats entry — prefer lazy
No magic: incompatible majors = either duplicate (default) or one shared (singleton, skew risk / strictVersion crash). So govern shared versions (renovate + single source of truth), don't rely on negotiation. Stateful libs (React/router/store) MUST dedupe; stateless utils (lodash) duplication = size cost only.
Resolution rule: a consumer takes the highest provided version that satisfies its own caret range, else its own copy (non-singleton) / forced onto highest-at-init + warn (singleton). Floats up, never below its floor, never across a major. Gotchas: ① exact requiredVersion pins ("18.2.0") defeat sharing even within a major → use carets. ② Widening requiredVersion:'>=18 <20' = consent to a higher major → no warning. ③ Singleton resolves at first consume (React = render time) → a late remote can't upgrade the live singleton, and can be pushed below its own required version. ④ Non-singleton late remote → loads its own copy at click time (duplication paid lazily).
Remote config, deploy & caching ⚠️
Terms (don't mix):remoteEntry.js = the entry file / pointer — tiny JS at a fixed un-hashed URL that names the content-hashed chunks holding the real code (e.g. cart.8f3a9c.js). mf-manifest.json (MF 2.0) = separate JSON metadata (exposes/shared/chunks). Model: one mutable pointer → many immutable chunks.
Static remote = URL baked in host build (rebuild to change/rollback). Dynamic = URL from runtime config via init()/loadRemote('cart/api') (no host rebuild).
Deploy: remote overwrites remoteEntry.js in place at the same URL → now points at new hashed chunks. Host fetches it next load → picks up new version, no host redeploy.
The paradox / #1 outage: fixed URL + cached entry → host reads OLD pointer, never updates. Old chunks purged → asks for a 404'd chunk → ChunkLoadError / white screen. (Fresh entry → loads new chunks ✓.)
immutable, max-age=31536000 (name changes on change = safe forever)
+ Retain old chunks through deploy window · runtime registry (name→version URL): deploy=update it, rollback=repoint it (config flip) · error boundary per remote. "Entry itself cached?" → there's always one pointer you can't cache; shrink it to a 1-line registry {"cart":"v1.2.3"} (no-cache) so every remoteEntry.js becomes immutable/versioned. Trade-off: mutable "latest" (cache race / no rollback) vs versioned+registry (control + clean rollback).
"Static+no-cache works — why dynamic?" Different axis. no-cache = "always-latest of a known URL." Dynamic = decide which URL / version / whether to load at runtime → pin & rollback w/o host redeploy, per-env URLs (1 artifact), add/remove remotes w/o touching host, conditional/A-B load, atomic versioned rollout. Static+no-cache only gives auto-latest (no pin/rollback).
config.json = host-level registry (name→current versioned entry URL); edit a line = release/rollback. Lives on shell CDN / edge-KV / flag-service (platform-owned). This is THE no-cache file → everything below it (versioned remoteEntry.js + hashed chunks) is immutable. Caveats: on the critical path (inline into HTML / edge-cache) + single source of truth (needs fallback). 3 pointer files, 2 levels: config.json (host: name→version) · remoteEntry.js (remote: modules→chunks) · mf-manifest.json (remote: metadata).
Communication (loosest coupling wins)
Channel
When
URL / router
loosest — navigational/shareable state (L13). Nothing imports anything.
tightest — only truly global state (auth/cart). The "shared-state mess".
Type the contract: events/stores have no compile-time contract → producer changes payload, silently breaks consumers. Fix = shared versioned contracts package (names + types) both sides import. Never reach into another MFE's DOM/store — only the published contract.
Feature touching N domains
1. Challenge boundaries — touching every MFE = seams mis-drawn (follow business domains; Conway). 2. One owner (host/shell). 3. Host orchestrates via typed contracts, no reaching in. 4.Flag-gate rollout — remotes deploy independently, can't ship atomically. 5. Cross-domain transaction → BFF/backend, not browser events. Punchline: in a monorepo it's 1 atomic, typed PR — the strongest monorepo-over-MFE argument.
Rapid-fire
Routing — shell owns top route, delegates prefix to remote (single-spa activity fns)
CSS isolation — CSS Modules / hashed classes / Shadow DOM + design tokens; global resets = #1 bug
Error isolation — error boundary per remote → no white-screen (fate-sharing fix)
SSR + MF — hard (client-runtime-first) → server-side composition / SSR+stitch
Version/rollback — versioned immutable remoteEntry; repoint to roll back, no shell redeploy
Testing — isolate each MFE + contract tests on seams + thin e2e on shell (L21)
2 Reacts — singleton:true on react/react-dom
Monorepo (the often-better first answer)
Monorepo
Polyrepo
Code sharing
trivial (local pkg)
publish/version npm
Cross-cut change
atomic — 1 PR app+lib
multi-repo dance
CI
needs affected+cache
naturally scoped
Why it scales = project graph + “affected” + caching: diff Git → rebuild/test only touched projects + dependents; hash inputs (src+config+env) → replay from local/remote cache (shared across team/CI). Cuts CI up to 90%.
Nx — full platform: graph viz, generators, module-boundary lint, distributed exec, remote cache. Big orgs.
Module-boundary enforcement (Nx) = killer Lead point: tag libs (scope:checkout,type:ui), fail CI if checkout→admin or UI→data. Architecture as a lint rule — team boundaries at build time, no runtime federation.
Lead lines
MFE "Micro-frontends decouple teams, not code. I adopt them when org coordination is the bottleneck — not for speed; they make the app slower."
Monorepo "Monorepo and MFE are different axes — build-time layout vs runtime composition. I'd get 80% of the independent-teams win from a monorepo with affected builds + boundary lint, and add MFEs only for genuine independent deploys."
Deploy "remoteEntry.js is a pointer at a stable URL — the one file I never cache, while the content-hashed chunks I cache forever. Cache the pointer and a deploy that purges old chunks white-screens every returning user. Versions come from a tiny runtime registry, so rollback is a one-line config flip, not a host redeploy."
3. "Share code?" → that's a monorepo job. MFEs are for independent deploys; they make sharing harder.
4. Costs to name: dup bundles · version skew · lost types/fate-sharing · shared-state mess · ops.
5. Build-time integration (npm pkg) re-couples deploys → defeats the purpose. Runtime JS is the sweet spot.
6.singleton:true = one shared React; the version negotiation is where skew crashes a remote at runtime.
7. Platform: monorepo backbone (shared DS + boundary lint + affected CI) default; MFEs only at true deploy seams, SSR-composed to protect SEO/LCP.
8. Dep resolution: default = highest-compatible-else-duplicate; singleton = one copy, highest wins + warn; strictVersion = crash on mismatch. React MUST be singleton.
9. Communicate by loosest channel (URL → props → events → shared store) + type events with a shared contracts pkg. Never reach into another MFE's internals.
10. Feature spanning N MFEs → challenge the boundaries first; if real, host orchestrates via typed contracts + flag-gate rollout (independent deploys can't ship atomically). Monorepo = 1 atomic PR.
11.remoteEntry.js (the pointer) = no-cache; hashed chunks = immutable. Cache the pointer + purge old chunks = ChunkLoadError white-screen. Keep old chunks through the deploy.
12. Change/rollback a remote version without host redeploy → dynamic remotes + runtime manifest (repoint = rollback). Static build-time URLs need a host rebuild.
13. Dynamic ≠ "for freshness" (that's no-cache). Dynamic = runtime control of which URL/version/whether → pin/rollback/per-env/conditional. config.json registry = the one no-cache file; everything below it immutable.