Dynamically Loading Remote Modules at Runtime #

You need to mount a federated remote whose URL is only known at runtime — chosen by a registry lookup, a feature flag, or a tenant config — without ever listing it in the host’s static remotes map.

The static remotes field in Configuring Webpack Module Federation hardwires remote URLs at build time. That breaks the moment you want to swap a remote per environment, A/B-test two versions of the same remote, or onboard a new team’s app without rebuilding the shell. The fix is to skip the static map entirely and drive the Module Federation runtime by hand: inject the remoteEntry script, initialize the shared scope, then pull the exposed module out of the container.

This guide builds a typed loadRemote() helper that does exactly that, with container caching and the share-scope handshake done correctly.

Runtime remote loading sequence Host injects the remoteEntry script, initializes the shared scope, gets the exposed module from the container, then renders it. Host shell Fetch remoteEntry.js Init shared scope container.get (module) Render exposed component URL comes from a registry or flag — never from static remotes
The runtime handshake: inject the script, init the shared scope, get the module, then render — all driven by a URL resolved at runtime.

Prerequisites #

Step 1 — Leave the remote out of the static config #

Do not list the dynamic remote in ModuleFederationPlugin. You still want the plugin so the host registers its own shared scope, but its remotes map stays empty (or only contains genuinely static remotes).

// webpack.config.js (host)
const { ModuleFederationPlugin } = require('webpack').container;

module.exports = {
  output: { publicPath: 'auto' },
  plugins: [
    new ModuleFederationPlugin({
      name: 'host',
      // No `remotes` for the dynamic one — we resolve its URL at runtime.
      remotes: {},
      shared: {
        react: { singleton: true, requiredVersion: '^18.2.0' },
        'react-dom': { singleton: true, requiredVersion: '^18.2.0' },
      },
    }),
  ],
};

Keeping publicPath: 'auto' matters: the loaded remote resolves its own chunk URLs relative to where its remoteEntry.js lives, which is the foundation of automatic publicPath configuration for remotes.

Step 2 — Inject the remoteEntry script #

The container global only exists once remoteEntry.js has executed. Write a small loader that appends the script tag and resolves when it loads. Reject on error so callers can fall back.

// loadRemoteEntry.ts
const entryPromises = new Map<string, Promise<void>>();

export function loadRemoteEntry(url: string): Promise<void> {
  if (entryPromises.has(url)) {
    return entryPromises.get(url)!;
  }

  const promise = new Promise<void>((resolve, reject) => {
    const script = document.createElement('script');
    script.src = url;
    script.type = 'text/javascript';
    script.async = true;
    script.onload = () => resolve();
    script.onerror = () =>
      reject(new Error(`Failed to fetch remoteEntry: ${url}`));
    document.head.appendChild(script);
  });

  entryPromises.set(url, promise);
  return promise;
}

Caching the promise by URL guarantees the same remoteEntry.js is fetched exactly once even if ten components request it simultaneously.

Step 3 — Initialize the shared scope, then init the container #

This is the handshake everyone gets wrong. Before you can pull a module out of a container, two things must happen in order:

  1. __webpack_init_sharing__('default') — populates the host’s default share scope with the host’s own shared modules.
  2. container.init(__webpack_share_scopes__.default) — hands that populated scope to the remote so it deduplicates React, etc., against the host.
// initContainer.ts
declare const __webpack_init_sharing__: (scope: string) => Promise<void>;
declare const __webpack_share_scopes__: Record<string, unknown>;

interface Container {
  init(shareScope: unknown): Promise<void> | void;
  get(module: string): Promise<() => unknown>;
}

let sharingInitialized = false;

export async function initContainer(
  container: Container
): Promise<Container> {
  if (!sharingInitialized) {
    await __webpack_init_sharing__('default');
    sharingInitialized = true;
  }
  // init() is idempotent guarded by container caching (Step 5),
  // but calling it twice on the same container throws — so we
  // only ever reach this once per container instance.
  await container.init(__webpack_share_scopes__.default);
  return container;
}

Guarding __webpack_init_sharing__ with a module-level flag prevents the “double init” warning when several remotes load in the same session — the default scope only needs seeding once per page.

Step 4 — Read the container off the global (handle both entry types) #

How you reach the container depends on how the remote declared its library. With type: 'var', the container is a plain object on window[globalName]. Newer setups expose a promise-based entry where window[globalName] is a thenable you must await.

// getContainer.ts
import type { Container } from './initContainer';

export async function getContainer(globalName: string): Promise<Container> {
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  const candidate = (window as any)[globalName];

  if (!candidate) {
    throw new Error(
      `Container "${globalName}" is undefined — did remoteEntry.js load?`
    );
  }

  // Promise-based entry: `library: { type: 'promise', ... }` or
  // an exposes value written as `promise new Promise(...)`.
  // The global resolves to the real container.
  const container =
    typeof candidate.then === 'function' ? await candidate : candidate;

  return container as Container;
}

If the remote was built with type: 'var', candidate is already the container and the then check is a no-op. If it was built as a promise, awaiting it yields the container. One code path covers both.

Step 5 — Wrap it all in a typed, cached loadRemote() #

Now compose the pieces. Cache resolved containers by global name so repeated loads of the same remote skip the whole handshake, and return a typed factory result.

// loadRemote.ts
import { loadRemoteEntry } from './loadRemoteEntry';
import { getContainer } from './getContainer';
import { initContainer, type Container } from './initContainer';

interface RemoteOptions {
  url: string;        // resolved from a registry or feature flag
  globalName: string; // the remote's ModuleFederationPlugin `name`
  module: string;     // exposed key, e.g. './Widget'
}

const containerCache = new Map<string, Promise<Container>>();

function resolveContainer(opts: RemoteOptions): Promise<Container> {
  const cached = containerCache.get(opts.globalName);
  if (cached) return cached;

  const promise = loadRemoteEntry(opts.url)
    .then(() => getContainer(opts.globalName))
    .then((container) => initContainer(container));

  containerCache.set(opts.globalName, promise);
  return promise;
}

export async function loadRemote<T = unknown>(
  opts: RemoteOptions
): Promise<T> {
  const container = await resolveContainer(opts);
  const factory = await container.get(opts.module);
  return factory() as T;
}

The container cache holds the promise, not the resolved value, so concurrent callers share a single in-flight handshake. Caching by globalName (not URL) is deliberate — Module Federation registers the container under its global name, and initializing the same name twice throws.

Step 6 — Consume it from React with a flag-driven URL #

The payoff: the host decides at runtime which remote to mount, from data it only has at runtime.

// RemoteWidget.tsx
import { lazy, Suspense, type ComponentType } from 'react';
import { loadRemote } from './loadRemote';

function fromRegistry(name: string) {
  // e.g. fetched from a config service or feature-flag SDK
  const registry: Record<string, string> = {
    'checkout-v2': 'https://cdn.example.com/checkout-v2/remoteEntry.js',
    'checkout-v3': 'https://cdn.example.com/checkout-v3/remoteEntry.js',
  };
  return registry[name];
}

const variant = 'checkout-v3'; // chosen by a flag at runtime

const Widget = lazy(() =>
  loadRemote<{ default: ComponentType }>({
    url: fromRegistry(variant),
    globalName: 'checkout',
    module: './Widget',
  })
);

export function RemoteWidget() {
  return (
    <Suspense fallback={<p>Loading widget…</p>}>
      <Widget />
    </Suspense>
  );
}

Because the shared scope is seeded once and handed to every container, React stays a singleton across the host and whichever remote you load — the same guarantee covered in managing shared dependencies at runtime.

Verification #

Confirm the remote really loads on demand and renders against the shared scope:

Troubleshooting #

Symptom: Shared module is not available for eager consumption. Diagnosis: container.init() ran before __webpack_init_sharing__('default') resolved, so the share scope was empty. Fix: ensure Step 3 awaits __webpack_init_sharing__ before container.init, and that the host’s own entry uses an async bootstrap (import('./bootstrap')) so the host’s shared modules are registered too.

Symptom: container.init is not a function or “container undefined”. Diagnosis: window[globalName] was read before remoteEntry.js finished, or globalName doesn’t match the remote’s ModuleFederationPlugin name. Fix: confirm the loadRemoteEntry promise resolved first, and that globalName equals the remote’s plugin name exactly — it’s case-sensitive. For a promise-based entry, make sure you await the global (Step 4).

Symptom: init() called twice warning, or shared modules silently re-registered. Diagnosis: container.init ran more than once on the same container — usually because two components loaded the remote concurrently without sharing a cache. Fix: cache the container promise by globalName as in Step 5 so all callers await one handshake. Keep the module-level sharingInitialized flag so __webpack_init_sharing__ also runs once.

Symptom: remoteEntry.js blocked by CORS, or 404 in production. Diagnosis: the remote’s server omits Access-Control-Allow-Origin, or the registry returned a stale/relative URL. Fix: serve remoteEntry.js with CORS headers allowing the host origin, and store absolute URLs in the registry. With publicPath: 'auto' the remote still resolves its own chunks correctly from wherever its entry was fetched.