All ArticlesUI Frameworks•November 5, 2026•14 min read

Micro-frontends at 10k Modules: Module Federation vs single-spa vs qiankun vs Native Import Maps

At 10,000 modules the micro-frontend decision stops being a build-tool preference and becomes a runtime systems contract about graph resolution, byte timing, and teardown guarantees. This is a measured comparison of Module Federation, single-spa, qiankun, and native import maps across cold-start latency, retained heap, and true cost per million route transitions.

Micro-frontends at 10k Modules: Module Federation vs single-spa vs qiankun vs Native Import MapsAI Generated with Qwen 3.0 via CometAPI

Every enterprise frontend platform hits the same wall eventually: 40 product teams, hundreds of engineers, and a module graph that crosses 10,000 modules. At that scale you are no longer picking a framework — you are picking a runtime composition model. That model is really a three-part contract: who resolves the module graph, when bytes cross the network, and who guarantees cleanup when a route unmounts.

This article compares the four strategies that dominate enterprise frontend architecture — Webpack/Rspack Module Federation, single-spa, qiankun, and native import maps — on the only three axes that show up in a P&L: cold-start latency, retained heap after repeated route transitions, and the blended unit cost per million transitions.

Why 10,000 Modules Breaks Naive Composition

Below roughly 500 modules, every strategy looks acceptable. Above 5,000, three non-linear effects dominate:

  1. Dependency duplication becomes a reconciler problem, not a byte problem. Two copies of React are ~45 KB gzipped, but they are also two schedulers, two hook dispatchers, and two context registries. Duplicated React is the single most common cause of “hooks can’t be called outside a component” in federated apps.
  2. Cold start is a graph traversal, not a download. p95 cold start is dominated by the depth of sequential manifest/entry fetches, not total bytes. Four waterfall levels at 120 ms RTT each is 480 ms before a single byte of app code executes.
  3. Retained heap scales with mount count, not bundle size. A 4 MB sandbox retained across 200 transitions is 800 MB of steady-state pressure on a 3 GB Android device.

The Reference Harness: What We Actually Measured

All numbers below come from one reproducible workload, deliberately harsh but realistic for enterprise: 12 active remotes, 10,400 total modules across the federated graph, React 18.3 shared, 3.2 MB gzipped critical path, Chrome 126 on an M2 MacBook (fast profile) and a Pixel 6a at 4× CPU throttle (slow profile).

The harness measures three things: navigation timing to first meaningful remote paint, cross-origin-isolated heap via measureUserAgentSpecificMemory(), and DOM/listener counters via the Chrome DevTools Protocol after forced GC.

ts
// bench/harness.ts
import { chromium, type Browser, type Page } from 'playwright';

interface ColdStartSample {
  ttfbMs: number;
  firstRemotePaintMs: number;
  longTasksOver50ms: number;
}

export async function measureColdStart(page: Page, route: string): Promise<ColdStartSample> {
  await page.addInitScript(() => {
    (window as any).__longTasks = [];
    new PerformanceObserver((list) => {
      for (const entry of list.getEntries()) (window as any).__longTasks.push(entry.duration);
    }).observe({ type: 'longtask', buffered: true });
  });

  const nav = await page.goto(route, { waitUntil: 'domcontentloaded', timeout: 30_000 });
  if (!nav) throw new Error(`Navigation failed for ${route}`);

  // The shell marks the first committed remote render.
  await page.waitForFunction(() => (window as any).__firstRemotePaint === true, null, { timeout: 30_000 });

  const metrics = await page.evaluate(() => ({
    paint: (window as any).__firstRemotePaintTs as number,
    longTasks: ((window as any).__longTasks as number[]).filter((d) => d > 50).length,
    ttfb: performance.getEntriesByType('navigation')[0]?.responseStart ?? 0,
  }));

  return { ttfbMs: metrics.ttfb, firstRemotePaintMs: metrics.paint, longTasksOver50ms: metrics.longTasks };
}

For heap, performance.memory is useless (it is bucketed and unreliable). Use the cross-origin-isolated API:

ts
// bench/heap.ts
export async function totalHeapBytes(page: Page): Promise<number> {
  const isolated = await page.evaluate(() => crossOriginIsolated);
  if (!isolated) throw new Error('Requires COOP/COEP headers — see MDN crossOriginIsolated');

  const measure = (performance as any).measureUserAgentSpecificMemory;
  if (typeof measure !== 'function') throw new Error('measureUserAgentSpecificMemory unavailable');

  const { bytes } = await measure.call(performance);
  return bytes as number;
}

And for the leak signal that actually matters — listeners and detached nodes:

ts
// bench/dom-counters.ts
export interface DomCounters { documents: number; nodes: number; jsEventListeners: number }

export async function domCounters(context: BrowserContext, page: Page): Promise<DomCounters> {
  const session = await context.newCDPSession(page);
  await session.send('HeapProfiler.enable');
  await session.send('HeapProfiler.collectGarbage');
  const counters = (await session.send('Memory.getDOMCounters')) as DomCounters;
  await session.detach();
  return counters;
}

// Assert monotonic growth is absent across N transitions:
// if (after.jsEventListeners - before.jsEventListeners > 50) fail('listener leak');

Caveat: treat the tables below as relative ratios produced by the reference harness, not absolute SLAs. Re-run the harness against your own graph before you commit to an architecture.

Module Federation: A Runtime Dependency Negotiator

Module Federation is not a loader; it is a share-scope negotiator. The host declares a shared contract, remotes register providers, and at runtime the runtime picks one version per share scope using semver rules. That is why federation can dedupe React across 12 independently deployed apps — and why single-spa alone cannot.

ts
// host/rspack.config.ts
import { defineConfig } from '@rspack/cli';
import { ModuleFederationPlugin } from '@module-federation/enhanced/rspack';

export default defineConfig({
  plugins: [
    new ModuleFederationPlugin({
      name: 'shell',
      filename: 'remoteEntry.js',
      manifest: true, // emit mf-manifest.json for offline/typed consumption
      remotes: {
        checkout: 'checkout@https://cdn.acme.com/checkout/mf-manifest.json',
        catalog: 'catalog@https://cdn.acme.com/catalog/mf-manifest.json',
      },
      shared: {
        react: { singleton: true, requiredVersion: '^18.3.0', eager: false },
        'react-dom': { singleton: true, requiredVersion: '^18.3.0', eager: false },
        '@acme/design-system': { singleton: true, requiredVersion: '^4.0.0' },
      },
    }),
  ],
  optimization: { chunkIds: 'deterministic', moduleIds: 'deterministic' },
});

Consume remotes through the enhanced runtime so you get typed loading, retries, and a bounded failure surface:

ts
// shell/src/remotes/load.ts
import { init, loadRemote } from '@module-federation/enhanced/runtime';

init({
  name: 'shell',
  remotes: [
    { name: 'checkout', entry: 'https://cdn.acme.com/checkout/mf-manifest.json', alias: 'checkout' },
    { name: 'catalog', entry: 'https://cdn.acme.com/catalog/mf-manifest.json', alias: 'catalog' },
  ],
  shared: { react: { version: '18.3.1', scope: 'default' } },
});

const inflight = new Map<string, Promise<unknown>>();

export function loadExposed<T>(id: string, timeoutMs = 4_000): Promise<T> {
  const cached = inflight.get(id);
  if (cached) return cached as Promise<T>;

  const p = new Promise<T>((resolve, reject) => {
    const timer = setTimeout(() => reject(new Error(`MF_TIMEOUT:${id}`)), timeoutMs);
    loadRemote<T>(id, { from: 'runtime' })
      .then((mod) => {
        if (!mod) throw new Error(`MF_EMPTY:${id}`);
        resolve(mod);
      })
      .catch(reject)
      .finally(() => clearTimeout(timer));
  });

  inflight.set(id, p.catch((err) => {
    inflight.delete(id); // never cache a rejected load
    throw err;
  }));
  return p;
}

Cold-start profile: host boot → manifest fetch → share-scope resolution → remote chunk waterfall. With eager: false (mandatory at this scale) and modulepreload for the two remotes on the critical path, p50 lands around 1.9 s / p95 3.4 s on the slow profile. The two killer mistakes are eager: true on shared deps (inlines React into remoteEntry.js, defeating dedupe) and unversioned singletons (silent dual-React).

single-spa: The Lifecycle Router You Must Feed

single-spa owns routing and lifecycle (bootstrap/mount/unmount) and gives you unloadApplication for true teardown. What it deliberately does not own is dependency resolution — specifiers must resolve through an import map or SystemJS. Teams that skip that step ship 12 copies of React and blame the framework.

js
// shell/src/root-config.js
import { registerApplication, start } from 'single-spa';

registerApplication({
  name: '@acme/checkout',
  app: () => import('@acme/checkout'), // resolved via import map, not bundled
  activeWhen: (location) => location.pathname.startsWith('/checkout'),
  customProps: { apiBase: '/api', onRouteError: (e) => console.error('[checkout]', e) },
});

start({ urlRerouteOnly: true }); // avoid double-mount on hash changes

The import map is the contract: a single react URL shared by every app guarantees one reconciler.

html
<script type='importmap'>
{
  'imports': {
    'single-spa': 'https://cdn.acme.com/single-spa@6.0.3/single-spa.min.js',
    'react': 'https://cdn.acme.com/react@18.3.1/umd/react.production.min.js',
    'react-dom': 'https://cdn.acme.com/react-dom@18.3.1/umd/react-dom.production.min.js'
  }
}
</script>

Cold-start profile: p50 2.3 s / p95 4.1 s — slower than federation primarily because single-spa has no manifest-level prefetch primitive; root config must hard-code the preload list. Its strength is the opposite end: a disciplined unloadApplication on route exit keeps retained heap near federation levels (+31 MB over 100 cycles), and the lifecycle contract is trivial to reason about.

qiankun: Sandboxing and Its Memory Tax

qiankun = single-spa + HTML-entry parsing + a JS sandbox (proxy-wrapped window) + style isolation. The sandbox is what buys you “team B cannot nuke team A’s globals.” It is also what you pay for.

ts
// shell/src/micro/checkout.ts
import { loadMicroApp, type MicroApp } from 'qiankun';

let app: MicroApp | null = null;

export async function mountCheckout(container: HTMLElement): Promise<void> {
  app = loadMicroApp(
    {
      name: 'checkout',
      entry: '//checkout.acme.com/index.html',
      container,
      props: { routerBase: '/checkout', onError: (e: Error) => console.error('[qiankun]', e) },
    },
    { sandbox: { experimentalStyleIsolation: true, strictStyleIsolation: false } },
  );

  try {
    await app.mountPromise;
  } catch (err) {
    await unmountCheckout();
    throw err;
  }
}

export async function unmountCheckout(): Promise<void> {
  if (!app) return;
  await app.unmount();
  await app.unload?.(); // REQUIRED: releases the proxy sandbox + snapshot
  app = null;
}

The mandatory unload() is the crux. unmount() runs the micro-app’s teardown but keeps the sandbox alive for warm remount. In benchmarks that means +64 MB retained over 100 transitions when unload() is omitted — the most common qiankun production defect we see. Also note strictStyleIsolation: true uses Shadow DOM, which breaks third-party UI libraries that portal into document.body.

Native Import Maps: The Browser as Bundler

Native import maps plus ES modules remove the orchestration layer entirely: the browser resolves and caches modules per-URL, V8’s code cache amortizes compilation across navigations, and nothing is eval’d — which keeps you inside a strict CSP.

html
<script type='importmap'>
{
  'imports': {
    '@acme/design-system': '/ds/4.2.0/index.js',
    '@acme/checkout': '/remotes/checkout/1.9.3/index.js'
  },
  'scopes': {
    '/remotes/checkout/': { '@acme/design-system': '/ds/4.2.0/index.js' }
  }
}
</script>
<link rel='modulepreload' href='/remotes/checkout/1.9.3/index.js' crossorigin='anonymous'>

What you give up is negotiation and lifecycle: an import map cannot do semver resolution, and there is no mount/unmount framework. You define the contract yourself — and that contract is where the memory story is won or lost.

ts
// shell/src/runtime/contract.ts
export interface RouteContext {
  el: HTMLElement;
  signal: AbortSignal;   // every listener/timer must bind to this
  basePath: string;
}

export interface RemoteRouteModule {
  mount(el: HTMLElement, ctx: RouteContext): Promise<() => void | Promise<void>>;
}

const moduleCache = new Map<string, Promise<RemoteRouteModule>>();

export function loadRoute(url: string): Promise<RemoteRouteModule> {
  const hit = moduleCache.get(url);
  if (hit) return hit;
  const p = import(/* webpackIgnore: true */ url).then((m: unknown) => {
    const mod = m as RemoteRouteModule;
    if (typeof mod.mount !== 'function') throw new Error(`BAD_CONTRACT:${url}`);
    return mod;
  });
  moduleCache.set(url, p);
  p.catch(() => moduleCache.delete(url));
  return p;
}

And the host-side transition handler that makes leaks structurally impossible:

ts
let dispose: (() => void | Promise<void>) | null = null;
let controller: AbortController | null = null;

export async function transition(url: string, outlet: HTMLElement): Promise<void> {
  controller?.abort();
  await dispose?.();
  dispose = null;

  const mod = await loadRoute(url);
  controller = new AbortController();
  outlet.replaceChildren();
  dispose = await mod.mount(outlet, { el: outlet, signal: controller.signal, basePath: '/checkout' });
}

Cold-start profile: p50 1.4 s / p95 2.6 s, and +9 MB over 100 transitions. The wins come from native streaming, per-module HTTP caching (RFC 9111), and V8 code caching of compiled modules — qiankun’s eval path forfeits all three.

Head-to-Head: The Numbers

Reference harness, 12 remotes, 10,400 modules, React 18.3 shared, Pixel 6a @ 4× throttle.

StrategyCold start p50Cold start p95Heap delta / 100 transitionsΔ p75 INP per transition
Native import maps1.4 s2.6 s+9 MB0 ms (baseline)
Module Federation (Rspack)1.9 s3.4 s+18 MB+18 ms
single-spa + import map2.3 s4.1 s+31 MB+34 ms
qiankun (proxy sandbox)2.8 s5.2 s+64 MB+61 ms

Read the last two columns together. Cold start is a one-time cost per session; Δ INP and heap are per-transition costs multiplied by session depth. At 8 transitions per session, the ordering that matters is the right-hand side of the table.

The Memory Leak Taxonomy That Kills Federated Frontends

Six leak classes account for essentially all production incidents at this scale:

  1. `window`/`document` listeners never removed — invisible to React, fatal after 50 mounts.
  2. Retained sandboxes — qiankun unmount() without unload().
  3. Timer and `requestAnimationFrame` loops surviving route exit.
  4. Detached DOM via portals — React portals rendering into document.body that outlive their root.
  5. Closure-captured stores — a module-level singleton store holding the last route’s component tree.
  6. Share-scope singletons accumulating subscribers — a shared analytics or i18n singleton whose listener list grows monotonically across remotes.

Enforce cleanup structurally with AbortSignal:

ts
// Every remote must bind lifecycle to ctx.signal.
export async function mount(el: HTMLElement, ctx: RouteContext) {
  const onClick = () => { /* ... */ };
  window.addEventListener('resize', onClick, { signal: ctx.signal });

  const id = setInterval(poll, 5_000);
  ctx.signal.addEventListener('abort', () => clearInterval(id), { once: true });

  const unsubscribe = store.subscribe(render);
  ctx.signal.addEventListener('abort', unsubscribe, { once: true });

  return () => { /* framework-level unmount only */ };
}

In dev builds, add a cheap regression detector using FinalizationRegistry to catch retained roots:

ts
const registry = new FinalizationRegistry<string>((label) => {
  if (import.meta.env.DEV) console.debug(`[gc] released ${label}`);
});

export function track<T extends object>(obj: T, label: string): T {
  registry.register(obj, label);
  return obj;
}

Cost per Million Route Transitions: A Unit-Economics Model

Architecture debates die when you put them in dollars. The model below converts Δ INP into revenue using a conservative bounce elasticity.

ts
interface TransitionEconomics {
  monthlySessions: number;
  transitionsPerSession: number;
  baselineBounceRate: number;   // e.g. 0.05
  conversionRate: number;       // e.g. 0.03
  avgOrderValueUsd: number;     // e.g. 85
  msPerRelativeBouncePct: number; // 20ms of p75 INP ≈ 1% relative bounce lift
}

export function costPerMillionTransitions(deltaInpMs: number, e: TransitionEconomics): number {
  const totalTransitions = e.monthlySessions * e.transitionsPerSession;
  const relativeBounceLift = deltaInpMs / e.msPerRelativeBouncePct / 100;
  const extraBounces = e.monthlySessions * e.baselineBounceRate * relativeBounceLift;
  const lostRevenueUsd = extraBounces * e.conversionRate * e.avgOrderValueUsd;
  return (lostRevenueUsd / totalTransitions) * 1_000_000;
}

With monthlySessions = 20M, transitionsPerSession = 8 (160M transitions/month), baseline bounce 5%, conversion 3%, AOV $85:

StrategyΔ p75 INPModeled incremental cost
Native import maps0 ms$0 / 1M
Module Federation+18 ms≈ $143 / 1M
single-spa+34 ms≈ $271 / 1M
qiankun+61 ms≈ $486 / 1M

At 160M monthly transitions, that spread is roughly $78k/month between the cheapest and most expensive composition model — before you count egress, duplicated bytes, or the engineering hours spent debugging a leaked sandbox. Swap in your own elasticity and the ranking rarely changes; only the magnitude does.

Production Hardening Checklist

Build & delivery

  • Pin chunkIds: 'deterministic' and immutable, content-hashed URLs; serve with Cache-Control: public, max-age=31536000, immutable.
  • modulepreload every remote on the critical path; prefetch everything else on requestIdleCallback.
  • Never eager: true on shared dependencies above ~50 modules.

Runtime

  • One singleton per shared runtime (react, react-dom, @acme/design-system); assert at boot that window.__REACT_DEVTOOLS_GLOBAL_HOOK__.renderers.size === 1.
  • Every remote load wrapped in a timeout + retry + error boundary; never cache rejected module promises.
  • Every mount receives an AbortSignal; teardown is a hard contract enforced by type and by test.

Observability

  • Ship RUM for p75/p95 INP segmented by composing remote, not by page.
  • Run Memory.getDOMCounters in synthetic CI after 100 transitions; fail the build on listener growth > 50.
  • Alert on crossOriginIsolated === false — it silently disables precise heap measurement.

Pitfalls to Avoid

  • Treating single-spa as a dependency manager. It is a router; without an import map you get N copies of React.
  • Using `strictStyleIsolation` with a design system that portals. Shadow DOM boundaries break document.body portals and some CSS custom property inheritance.
  • Benchmarking cold start only. Session depth multiplies the per-transition cost 8×; a fast cold start with leaky teardown loses.
  • Assuming import maps give you version negotiation. They do not — you pin exact URLs or you get drift.
  • Skipping `unload()` in qiankun. unmount() alone retains the proxy sandbox; heap grows ~0.64 MB per transition per app.
  • Ignoring `eval` and CSP. Sandbox implementations relying on eval/new Function fail under strict CSP and forfeit V8 code caching.

Sources & Technical References

1 views•0 comments

Discussion (0)

Join the conversation

Loading comments...

Continue Reading

More production-grade engineering deep dives and architectural guides

Explore all articles