Portfolio/Writing/Micro-frontend integration patterns: Module Federation vs import maps vs build-time

Micro-frontend integration patterns: Module Federation vs import maps vs build-time

A practical comparison of the ways to compose a front-end from independently owned pieces (npm packages, server-side composition, Module Federation, native import maps, web components), and how to pick one.

"Micro-frontend" is not one technique. It is a goal, letting teams ship parts of a UI independently, and it has at least five distinct implementations, each making a different trade between isolation, simplicity and runtime risk. Picking the wrong one is expensive, because the integration seam is the hardest thing to change later.

This is a tour of the options along a single axis: when does composition happen, at build time, on the server, or in the browser.

Build-time integration (npm packages)#

Each team publishes its feature as a versioned package; the shell app installs them and bundles everything together.

json
// shell/package.json
{
  "dependencies": {
    "@acme/checkout": "4.2.0",
    "@acme/search": "2.9.1"
  }
}
ProsCons
One bundle, one React, no runtime negotiationEvery feature release needs a shell bump + redeploy
Full type safety across the seamTeams are coupled to the shell release train
Trivial to reason about, debug, testA slow shell pipeline slows everyone
Best performance (shared tree-shaking)Not really independent deployment
Underrated
For most teams that think they need micro-frontends, a monorepo with per-package ownership, code-splitting and a fast CI pipeline delivers most of the benefit with none of the runtime failure modes. Rule this out deliberately before moving on.

Server-side / edge composition#

Each micro-frontend renders its own HTML fragment; a composition layer stitches fragments into one document. Server-Side Includes, ESI at the CDN, Tailor/Podium-style layout services, or modern takes like fragment embedding in Astro.

html
<!-- layout service output -->
<body>
  <!--#include virtual="/fragments/header" -->
  <main>
    <!--#include virtual="/fragments/product?id=42" -->
  </main>
</body>
ProsCons
Great first paint; SEO-friendly HTMLNeeds a server / edge composition tier to run and own
Fragments can use different stacksClient-side interactivity across fragments is awkward
Failure of one fragment can be isolated to a slotShared client state / routing needs extra plumbing

Client-side: Module Federation#

The shell loads remote modules over the network at runtime and shares libraries through a negotiated scope. Covered in depth in the Module Federation post; the short version:

ProsCons
True independent deploy; hot-swap a remote with no shell redeployNew runtime failure modes (remote down, singleton clash)
Rich shared-dependency semver negotiationVersion skew becomes a distributed coordination problem
Lazy by default; remotes cost nothing until renderedTypes, debugging and SSR all need extra infrastructure
Framework-agnostic, mature tooling (Webpack, Rspack)Needs a platform team to own the shared contract

Client-side: native import maps#

An import map tells the browser where bare specifiers resolve. It is a web standard, supported in all current evergreen browsers, and needs no bundler runtime at all.

html
<script type="importmap">
{
  "imports": {
    "react": "https://esm.sh/react@18.2.0",
    "react-dom/client": "https://esm.sh/react-dom@18.2.0/client",
    "@acme/checkout": "https://cdn.acme.com/checkout/v4/index.js",
    "@acme/search": "https://cdn.acme.com/search/v2/index.js"
  }
}
</script>

<script type="module">
  const { mountCheckout } = await import("@acme/checkout")
  mountCheckout(document.getElementById("checkout-slot"))
</script>

Every remote resolves import "react" to the same URL, so the browser fetches and evaluates React once and every micro-frontend shares that module instance. That is the entire "shared dependency" mechanism. No share scope, no negotiation, just URL identity.

What you get

  • Zero runtime library; the browser does the resolution.
  • Independent deploys: change a CDN URL in the map, or version the path (/checkout/v4/).
  • Trivial mental model; native devtools; real source maps from the origin.
  • Works well with unbundled ESM (esm.sh, jsr) and with per-feature build outputs.

What you give up

  • No semver negotiation. You pin exact URLs. Two features needing incompatible React majors cannot both use the shared entry. You would knowingly load two, which breaks context and hooks identity.
  • No built-in fallback if a URL 404s or the CDN is down. You add that yourself.
  • The map is usually static. Dynamic import maps and multiple maps are landing but support is uneven; generating the map at deploy time from a manifest is the pragmatic move.
  • Bundling story. Shipping hundreds of unbundled ESM modules over HTTP/2 is fine at medium scale; very large apps still want per-feature bundles (which import maps happily point at).
  • Older build pipelines need es-module-shims for full coverage.
Sweet spot
Import maps shine when your platform libraries move in lockstep (one React, one design system, rolled forward for everyone at once) and your features are independent deploy units. That is a very common shape, and it is much simpler than a federation runtime.

Client-side: web components as the seam#

Each micro-frontend ships a custom element (<checkout-app>). The shell just places tags. Often combined with import maps or single-spa for loading.

ProsCons
Framework-agnostic boundary; strong DOM/style isolation via shadow DOMPassing rich data / callbacks through attributes & events is clunky
Lifecycle is the platform’s (connectedCallback)Shared context (theme, auth, router) needs a deliberate channel
Easy to embed the same widget in multiple hostsSSR + hydration for custom elements is still rough

The decision matrix#

If this is trueUse
Teams already release together; you want ownership boundariesBuild-time (monorepo + packages)
First paint / SEO is critical and you can run an edge tierServer-side composition
Independent deploy cadence + you can staff a platform teamModule Federation
Independent deploy cadence + platform libs move in lockstepImport maps
Multiple host apps embed the same widget across stacksWeb components
You are not sure yetBuild-time now; keep features split so you can switch later
text
BUILD TIME            SERVER / EDGE            BROWSER (runtime)
──────────            ────────────            ────────────────
npm i @acme/*         layout service          import map / remoteEntry
   │                    │  include slots         │  resolve specifiers
   ▼                    ▼                        ▼
one bundle           one HTML doc             shell + N async modules
one deploy           N fragment deploys      N independent deploys
no runtime risk      tier to operate         runtime negotiation / fallback
The same three-feature app, composed at three different moments.

Cross-cutting concerns you need regardless#

  1. Design system: one shared, versioned source of tokens/components, rolled forward on a schedule.
  2. Auth & session: one owner, exposed to features through a defined API, never re-implemented per feature.
  3. Routing: decide who owns the URL and how features claim sub-paths.
  4. Cross-feature events: a documented event bus or shared store, with a schema.
  5. Observability: every error and RUM sample tagged with which feature + version produced it.
  6. Failure UX: a standard skeleton/error boundary for "a feature failed to load".
Summary
The question is not "which micro-frontend framework" but "when does composition happen". Build-time is simplest and usually enough. If you need independent runtime deploys, import maps are the low-complexity default and Module Federation is the high-capability option that costs a platform team. Everything else is a variation on those.

Next up
Injecting environment variables into a Dockerized Vite app at runtime

Vite bakes import.meta.env into the bundle at build time, so one Docker image cannot be promoted across environments. This covers why that happens and a pattern where the container writes an env.js file at start-up from real process env.

Read next →