GAP·MAP

Micro-frontends

[ junior depth ]

The release boundary is the point

A micro-frontend is a frontend split into independently developed and deployed artifacts. A business-facing slice owns its UI and logic inside a defined boundary. The label says nothing about React, webpack, repository count, or component size.

Wrong "We have separate packages, so we have independently deployable micro-frontends."

Right A release is independent only when one team can deliver its artifact to users without another frontend team rebuilding and deploying the application.

Suppose Checkout publishes an npm package, then the shell must install the new version and ship again. Checkout has its own build, but its user-facing release still waits for the shell. This distinction is a common interview probe because teams often describe source-code separation as deployment independence.

Three composition models

The composition point determines where the pieces become one page.

Build-time composition makes the shell consume team-owned packages and produce one deployable artifact:

{
  "dependencies": {
    "@shop/catalog": "^4.2.0",
    "@shop/checkout": "^7.1.0"
  }
}

This is straightforward dependency management. It also puts delivery behind the shell's build and deployment.

Server-side composition has an origin server combine independently delivered HTML fragments before returning the page. The server must discover fragment endpoints, handle failures, and coordinate caching. A new fragment version can require rendered pages and caches to be refreshed.

Runtime composition loads and mounts separately built frontends in the browser. A shell or orchestration framework decides which bundle to load and where it renders. Module Federation, import maps, custom elements, and iframes are different ways to support this model. Runtime composition can preserve separate deployment paths, while compatibility and loading failures now occur in the running application.

Who owns the URL and the page

A route-level split gives one frontend a page or URL prefix, such as /account/*. A mixed page can mount several frontends at once. AWS guidance notes that mixed client-side composition needs client-side routing for the deeper routing hierarchy.

Define one owner for top-level navigation. A feature can own routes below its assigned prefix, but two routers should not compete to interpret the same URL or maintain unrelated histories. The URL is also a useful narrow interface for state that must survive navigation.

The same ownership rule applies to the DOM. One frontend should not modify nodes owned by another. Cross-boundary work can use documented events instead of reaching into another frontend's tree or store.

When the monolith wins

Micro-frontends add distributed-system concerns to frontend delivery: discovery, compatibility, failure handling, integration tests, and governance. AWS recommends judging that cost against team structure and release behavior. A small application, a few teams, or an application that changes slowly may gain no useful autonomy from the split.

Wrong "Micro-frontends are the modern default once an application becomes large."

Right Keep a well-modularized monolith when teams already release quickly and are not paying for high coupling or long lead times. The architecture earns its cost when independent delivery solves an observed organizational bottleneck.

[ middle depth ]

Module Federation composes independent builds at runtime

webpack's ModuleFederationPlugin lets one build provide or consume modules from another independent build at runtime. The host references a remote, and the remote exposes modules through a container entry. Loading a remote module is asynchronous; evaluation happens after the module has loaded.

new ModuleFederationPlugin({
  name: "checkout",
  filename: "remoteEntry.js",
  exposes: {
    "./CheckoutPage": "./src/CheckoutPage.js",
  },
});
new ModuleFederationPlugin({
  name: "shell",
  remotes: {
    checkout: "checkout@https://checkout.example/remoteEntry.js",
  },
});

The shell can then import the exposed module across an asynchronous boundary:

const CheckoutPage = await import("checkout/CheckoutPage");

This arrangement can support independent deployment because the checkout build is hosted separately. It does not create organizational autonomy by itself. If every remote change requires a coordinated shell change, the teams still release in lockstep.

Shared dependencies are a runtime contract

Sharing a library can avoid sending a separate copy with every remote. webpack's defaults and hints matter:

new ModuleFederationPlugin({
  shared: {
    react: {
      singleton: true,
      requiredVersion: "^19.0.0",
      strictVersion: true,
    },
  },
});

singleton: true permits one version in the shared scope. When several versions are present, webpack documents that it uses the highest semantic version. requiredVersion declares an acceptable range. strictVersion: true rejects an incompatible shared version. For a singleton, or when no local fallback exists, that rejection throws at runtime. A non-singleton with an available fallback can use its fallback instead.

Wrong "Singleton means every remote gets the exact dependency from its own lockfile."

Right The builds negotiate one shared runtime version. Their declared ranges and fallback settings determine whether that version is acceptable.

Sharing everything creates a wide compatibility surface. Sharing nothing gives each artifact maximum freedom, but common libraries are downloaded more than once. AWS documents both as conscious strategies. Choose the small set of dependencies whose duplication or global internal state warrants coordination.

webpack also supports eager: true, which puts the provided and fallback module in the initial chunk. Its documentation warns that all provided and fallback modules are then always downloaded. An asynchronous boundary is the documented recommendation for the common case.

Routing and communication need narrow owners

For route-level composition, the shell can map a prefix to a remote and let that remote handle paths beneath it:

const routes = {
  "/catalog": () => import("catalog/App"),
  "/checkout": () => import("checkout/App"),
};

One navigation contract should update browser history. A remote that owns /catalog/* can interpret its remaining path, while the shell retains top-level ownership. Server-side and edge-side composition align more naturally with server-side routing; client-side mixed composition needs client-side routing for nested views.

A global store shared by all frontends couples each release to its state shape and actions. AWS guidance recommends state inside each micro-frontend and asynchronous messages across boundaries. Make the event contract small and explicit:

window.dispatchEvent(
  new CustomEvent("cart:item-added", {
    detail: { productId: "p-42", quantity: 1 },
  }),
);

Consumers depend on the documented event payload. They do not read the producer's private store or DOM.

CSS containment is not runtime isolation

Shadow DOM scopes its tree and styles. Page selectors do not select nodes inside the shadow tree, and styles defined inside do not affect the rest of the page. An open shadow root remains accessible through host.shadowRoot; a closed root is still not a strong security mechanism.

An iframe creates a separate document and browsing context. Cross-origin script access is constrained by the same-origin policy, and sandbox can add restrictions. The boundary costs more memory and makes history, responsive layout, and cross-frame integration harder. Pick it when that isolation is worth those constraints, not as a default wrapper for every frontend.

[ senior depth ]

Independent deployment has more than one gate

An independent pipeline is necessary, but the user-facing path also includes artifact discovery, compatibility, routing, and shared runtime state. A team is still coupled when its release needs another team to update a manifest, change a shell route, or approve an incompatible shared dependency.

webpack's route-level Module Federation example makes one boundary visible: pages can be deployed separately, but adding or changing shell routes requires a shell deployment. That may be the right contract. It should be described honestly rather than hidden behind separate repositories.

Runtime discovery moves some coordination out of build time. A shell or server can fetch a manifest that identifies each artifact's name, URL, version, and fallback behavior. This supports deploying an artifact and updating its mapping through the owning pipeline. It also introduces a runtime dependency on discovery and artifact availability.

{
  "checkout": {
    "url": "https://cdn.example/checkout/7.4.1/remoteEntry.js",
    "version": "7.4.1",
    "fallback": "hide-checkout-entry"
  }
}

The contract needs a failure policy. A shell can preserve the last known compatible mapping, stop a rollout when integration checks fail, or render a bounded fallback for a noncritical feature. The exact policy follows the product's failure tolerance; runtime composition does not supply one.

Version negotiation can defeat a safe-looking rollout

The dangerous Module Federation misconception is that every build runs with the dependency tree it tested locally. Shared modules break that assumption by design.

For a singleton, webpack allows one version in the shared scope and selects the highest semantic version when multiple versions are present. requiredVersion expresses the consumer's range. strictVersion can turn an unsatisfied range into a runtime error. Without a compatible fallback and enforcement policy, a remote may run against a version it did not use in isolation.

Use explicit ranges and test the integrated host-remote set before changing its discovery mapping:

new ModuleFederationPlugin({
  shared: {
    react: {
      singleton: true,
      requiredVersion: "^19.0.0",
      strictVersion: true,
    },
    "@company/design-system": {
      singleton: true,
      requiredVersion: "^8.3.0",
      strictVersion: true,
    },
  },
});

For these singletons, this configuration chooses a runtime error when no compatible shared version is available. The application still needs to catch remote-loading failures at an appropriate boundary and expose them to monitoring. A strict version check enforces the declared ranges; it does not prove semantic compatibility between two independently released interfaces.

Wrong "A shared design system is harmless because it only contains presentation code."

Right A runtime-shared package is a cross-team contract. Its components, CSS expectations, and any stateful behavior can constrain independent releases. Build-time packages reduce runtime version risk, but require governance to move consumers to new versions.

Isolation is a graduated choice

The cheapest boundary is ownership: each frontend modifies only its own DOM subtree and state. Documented events carry cross-boundary facts. Shadow DOM adds DOM and CSS encapsulation, although MDN explicitly says a closed root is not a strong security mechanism.

An iframe provides a separate document. A cross-origin iframe also receives same-origin policy restrictions on script access; postMessage is the browser mechanism for cross-origin communication. Add sandbox permissions carefully. MDN warns that a same-origin frame with both allow-scripts and allow-same-origin can remove its sandbox attribute.

<iframe
  src="https://reports.example/"
  sandbox="allow-scripts"
  title="Reports"
></iframe>

That stronger boundary brings a complete document environment, increased resource use, and more difficult history and layout integration. Shadow DOM and iframes solve different isolation problems.

Choose the architecture from the organization

Micro-frontends have a clear payoff when autonomous teams own stable business boundaries and current release coupling causes long lead times. Their costs are also concrete: more artifacts, runtime compatibility, discovery, cache updates, integration testing, and observability.

AWS guidance recommends a monolith as a natural first step for a small application, even when growth is expected. Applications maintained by a few teams may not need micro-frontends when coupling and release lead time are already acceptable. A modular monolith keeps dependencies and internal data flow easier to manage while the organizational need for distributed delivery remains absent.

Wrong "Use micro-frontends to let every team choose its preferred framework."

Right Independent delivery is the architectural goal. Framework diversity can be tolerated during migration, but it also expands the dependency, testing, and user-experience surface that the platform must govern.

dig deeper

Primary sources behind these notes - the specs and official docs worth reading in full.

beta

The interviewer part is in the works.

The diagnostic, personal maps, and AI mock interviews are being finished right now. The notes stay free either way. Leave an email and you'll get the first-cohort invite, plus a month of Pro when it opens.

builds on

more System Design

was this useful?