Application Architecture at Scale
Micro-frontends, monorepo versus polyrepo, design system distribution, and the Backend-for-Frontend pattern - and when each one is worth its complexity.
Advertisement
Why It Matters
Every architecture decision at this level is really an answer to a question about people. How many teams, how independently do they need to ship, and how much coordination cost can the organisation absorb?
That is why the same architecture can be obviously correct at one company and obviously wrong at another with an identical product. The failure mode in interviews - and in practice - is adopting the architecture of a much larger organisation and inheriting all of its costs while having none of its problems.
Micro-Frontends: Three Integration Models
A micro-frontend is an independently developed and deployed piece of a user-facing application. The interesting question is never "should we?" in the abstract - it is where the seam is, because that determines everything.
Build-time integration (npm packages)
Each team publishes its piece as a versioned package. The shell installs them and builds one bundle.
The integration is a normal dependency. Type checking works across boundaries, the bundler can tree-shake and deduplicate, and a version mismatch is a build failure you see in CI.
But the coupling is total: shipping any change requires the shell to bump the version, rebuild, and redeploy. Teams are not independent - they are on a queue. If your reason for splitting was independent deploy cadence, this does not deliver it.
Runtime integration (Module Federation)
The shell loads remote modules over the network at runtime. Each team deploys to its own URL; the shell fetches and mounts their code on demand.
This is the model that actually delivers independent deployment. A team pushes, and the change is live for users on the next page load with no shell involvement.
The cost is that every guarantee build-time integration gave you is gone:
- Version skew is a runtime problem. The host can run React 18 while a remote was compiled against React 19, and you discover it when a user hits that route. Shared dependencies must be declared as singletons with version constraints, and even correct configuration only narrows the failure window.
- A remote being unreachable is a production error, not a red build. Every remote needs an error boundary and a fallback.
- Debugging crosses deployment boundaries. A stack trace can span three separately built and separately source-mapped applications.
Iframe-based integration
Each micro-frontend runs in its own iframe: separate document, separate JavaScript context, separate CSS.
This gives you the strongest isolation available in a browser. A crash, a CSS collision, or a memory leak in one is genuinely contained. It is the only sane option for embedding code you do not control or fully trust.
The costs are user-facing rather than technical. Routing and history are fragmented, modals cannot escape their frame, responsive layout across frames is painful, accessibility and focus management break at frame boundaries, and every frame reloads the framework runtime from scratch.
Diagram100%flowchart TB subgraph Browser["Browser - single page load"] Shell["Shell / Host application<br/>owns routing + layout"] subgraph Remotes["Runtime-loaded remotes"] R1["Checkout remote<br/>team-checkout.example.com<br/>remoteEntry.js"] R2["Search remote<br/>team-search.example.com<br/>remoteEntry.js"] end Shared["Shared singletons<br/>react, react-dom, design-system"] end Shell -->|"1 - fetch remoteEntry at runtime"| R1 Shell -->|"1 - fetch remoteEntry at runtime"| R2 R1 -.->|"2 - resolve against host version"| Shared R2 -.->|"2 - resolve against host version"| Shared Shell -.-> Shared D1["Deployed independently<br/>no shell rebuild"] -.-> R1 D2["Deployed independently<br/>no shell rebuild"] -.-> R2 style Shell fill:#1e3a5f,stroke:#3b82f6 style Remotes fill:#0f172a,stroke:#64748b style Shared fill:#3f2d1e,stroke:#f59e0bvisualized by
When Micro-Frontends Are Worth It
They solve exactly one problem well: multiple teams that need to deploy independently. If two teams currently have to coordinate a release train, or one team's broken test blocks another team's ship, micro-frontends address that directly.
Secondary cases that hold up: incrementally migrating a legacy application by routing slices to a new stack, or embedding a third-party product surface that must stay isolated.
Cases that do not hold up:
- One team. You were never blocked on yourself.
- "Different parts of the app feel unrelated." That is a module boundary, not a deployment boundary. Solve it with folders.
- "We want to try a new framework." Running two framework runtimes in one page to satisfy curiosity is a very expensive experiment.
- "It will scale better." Micro-frontends do not make anything faster. They usually make the bundle bigger, because deduplication across independently built remotes is imperfect at best.
Monorepo vs Polyrepo
Polyrepo - one repository per application or library - gives hard ownership boundaries and independent CI. The cost is that any change spanning repos becomes a chain of pull requests: publish a package, wait, bump the consumer, wait, deploy. Refactors that touch a shared interface become multi-week projects, and "which version of the design system is that team on?" becomes a question nobody can answer quickly.
Monorepo - everything in one repository - makes cross-cutting change a single atomic commit. You can rename a prop across all consumers in one pull request, with all their tests running against it. Every team is on the same version of everything by construction.
The costs are tooling costs, and they are real. Naively, every push runs every test. Tools like Nx and Turborepo exist to fix that: they build a dependency graph of the workspace, determine which packages a change actually affects, and run only that subset - then cache the results, locally and remotely, so unchanged packages are never rebuilt at all.
That caching is what makes a monorepo viable at scale, and choosing a monorepo without it is choosing a CI pipeline that gets slower every month.
Note that this decision is orthogonal to micro-frontends. Independently deployed micro-frontends developed in a single monorepo is a common and coherent setup - the repo boundary and the deploy boundary are different questions.
Distributing a Design System
The same component library, three distribution models, three different versioning problems:
npm package. Each consumer installs a version and upgrades when they choose. Simple, familiar, and it respects team autonomy. The problem is drift - after a year you have six teams on four versions, a bug fix has to be adopted six times, and "which version has that fix?" is a real support burden. Breaking changes require a migration campaign.
Monorepo shared package. One version, always. Consumers import from a workspace package with no publish step, and a breaking change is refactored across every consumer in the same commit. The problem is that it requires every consumer to live in that repo - which is not an option for a public library or a genuinely independent team.
Federated module. The design system is loaded at runtime, so a fix reaches every consumer without any of them redeploying. This is the only model with that property, and it is a genuine superpower for security patches. It is also the most dangerous: a bad release breaks all six applications simultaneously, with no per-team rollback, and consumers cannot pin a version to protect themselves. You have traded per-team blast radius for organisation-wide blast radius.
Most organisations should default to npm packages and treat version drift as a process problem - automated upgrade PRs, a supported-version policy - rather than an architecture problem.
Backend-for-Frontend
A BFF is a thin server-side layer owned by the frontend team, sitting between the client and the general-purpose backend services.
It exists because generic services are designed for all consumers, and the shape that suits everyone suits no one particularly well. Three specific mismatches:
Over-fetching. A user service returns fifty fields because someone needs each of them. A profile header needs four. On a mobile connection, the client pays for the other forty-six.
Under-fetching and waterfalls. One screen needs data from four services. Without a BFF, the client calls one, reads the response to learn what to call next, and serialises four round trips at browser-to-server latency instead of four calls inside the data centre.
Protocol mismatch. Internal services speak gRPC, or an internal message format, or require credentials the browser should never hold. Something has to translate.
Diagram100%flowchart LR subgraph Clients W["Web client"] M["Mobile client"] end subgraph BFFs["BFF layer - owned by frontend teams"] BW["Web BFF<br/>aggregate + reshape"] BM["Mobile BFF<br/>smaller payloads"] end subgraph Services["Backend services"] S1["User service<br/>gRPC"] S2["Catalog service<br/>REST"] S3["Pricing service<br/>REST"] S4["Inventory service<br/>gRPC"] end W -->|"1 request, exact shape"| BW M -->|"1 request, trimmed shape"| BM BW --> S1 BW --> S2 BW --> S3 BW --> S4 BM --> S1 BM --> S2 style BFFs fill:#1e3a5f,stroke:#3b82f6 style Services fill:#0f172a,stroke:#64748bvisualized by
The critical design rule: a BFF contains no business logic. It aggregates, reshapes, and translates. The moment pricing rules or entitlement checks migrate into it, you have built a second backend that the frontend team now has to operate, and you have duplicated logic that will drift from the service that owns it.
The cost is another deployable service in the request path - one more thing to monitor, scale, secure, and page someone about at 3am. And having one BFF per client type, which is the point of the pattern, means aggregation logic duplicated across them.
Tradeoffs
| Option | Pros | Cons | When to Use |
|---|---|---|---|
| Build-time micro-frontends | Type-safe across boundaries, bundler can optimise, mismatches fail in CI | No independent deploy - shell must rebuild for every change | Shared libraries, or a stepping stone toward runtime integration |
| Module Federation | Genuine independent deploy, shared singletons, no shell rebuild | Runtime version skew, remote failure is a production error, cross-app debugging | Multiple teams blocked on a shared release train |
| Iframe integration | Strongest isolation available - CSS, JS, crashes all contained | Broken routing and history, modal and focus problems, duplicated runtimes | Embedding untrusted or third-party surfaces |
| Monorepo | Atomic cross-cutting changes, one version of everything, shared tooling | Needs Nx or Turborepo to stay fast, weaker ownership boundaries, large clone | Multiple packages that change together |
| Polyrepo | Hard ownership, independent CI, small checkouts | Cross-repo changes are multi-PR, version drift, slow refactors | Genuinely independent products, or open-source libraries |
| BFF | Kills over-fetching and waterfalls, absorbs protocol mismatch, frontend-owned | Another service to run and monitor, duplicated per client type, tempting place to leak business logic | Several backend services behind one screen, or multiple client types with different payload needs |
Where This Applies
The dependency-graph and caching tooling that makes a monorepo survivable is covered in Build Tooling and Developer Experience. The waterfall problem a BFF solves is examined from the client side in Networking and Data Fetching. Iframe isolation appears again as a security primitive in Security Architecture, and the argument for accessible shared primitives in a design system is made in Accessibility Architecture.
In the applied practice problems, this shows up in Dashboard with Widgets, where a registry is the contract between a shell and widgets owned by different teams, and in Shopping Cart, where a server-side layer aggregates pricing, inventory, and tax behind a single call rather than letting the client assemble them.
Advertisement
When are micro-frontends the wrong choice?
When you have one team. Micro-frontends solve an organisational problem - independent teams needing to deploy without coordinating - and they pay for that with duplicated dependencies, a harder debugging story, cross-application version drift, and runtime failure modes that a monolith simply does not have. A single team splitting its own app into federated remotes gets all of that cost and none of the benefit, because they were never blocked on each other's release train in the first place. The honest test is whether two teams currently have to coordinate a deploy. If not, do not split.
What actually breaks in Module Federation that does not break with npm packages?
Version skew at runtime. With npm packages, integration happens at build time, so a mismatch is a build error you see in CI. With Module Federation the host loads remote code over the network at runtime, so the host can be running React 18 while a remote was built against React 19 and nobody finds out until a user hits that route. Shared dependencies have to be declared with explicit singleton and version constraints, and even then a remote that fails to load is a production runtime error, not a red build. You also need a fallback for the remote being unreachable at all.
What problem does a Backend-for-Frontend actually solve?
Three, and they are all shape mismatches. Over-fetching, where a generic service returns fifty fields for a screen that needs four and the client pays for the rest on a mobile connection. Waterfalls, where one screen needs data from four services and the client sequentially discovers what to call next. And protocol mismatch, where the internal services speak gRPC or an internal event format that the browser cannot consume. A BFF aggregates and reshapes on the server side of the network boundary, so the client makes one call and gets exactly the payload its screen needs.
You have a design system used by six teams. Should it be an npm package, a monorepo package, or a federated module?
It depends on whether you need every team to be on the same version at the same moment. An npm package gives each team the freedom to upgrade on their own schedule, which is what most organisations actually want, at the cost of six versions being live simultaneously. A monorepo shared package guarantees one version everywhere and lets you refactor all consumers in a single commit, but it requires every team in one repo and a build system that can scope work to the affected packages. A federated module is the only option that lets you push a fix to all six teams without any of them redeploying - which is powerful and dangerous for the same reason, since a bad release breaks everyone at once.
How do micro-frontends handle shared state and routing?
Carefully, and this is where most implementations get ugly. Shared state cannot go through a framework context, because each micro-frontend is a separate root and often a separate framework instance - so it goes through an explicitly designed contract, usually a small event bus or a store exposed by the shell, treated like a public API with versioning. Routing is owned by the shell, which maps URL segments to remotes and lets each remote own its subtree. The rule that keeps this sane is that micro-frontends communicate through narrow published contracts, never by reaching into each other.