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.
Prerequisites #
- Webpack 5.40+ (any version with
ModuleFederationPlugin) on both host and remote. - The remote built with
library: { type: 'var', name: '<globalName>' }so its container is exposed onwindow. (The promise-basedtypeis handled in Step 4.) - TypeScript 4.5+ if you want the typed helper; the runtime works in plain JavaScript too.
- The remote’s
remoteEntry.jsserved with permissive CORS headers (Access-Control-Allow-Origin) because the host fetches it cross-origin. - A host that does not declare this remote in its static
remotes— leave it out or setremotes: {}.
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:
__webpack_init_sharing__('default')— populates the host’sdefaultshare scope with the host’s own shared modules.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:
- Network tab, on demand: Open DevTools → Network and filter for
remoteEntry.js. It should appear only whenRemoteWidgetfirst renders — not on initial page load. Switchvariantand confirm a differentremoteEntry.jsURL is fetched. - Single React instance: In the console, the remote’s components should mount without “Invalid hook call” or duplicate-React warnings. A duplicate React means the shared scope was not handed over (revisit Step 3).
- Cached second load: Mount, unmount, and remount the widget. The second mount must show no new
remoteEntry.jsrequest — the container cache served it. - Module renders: The exposed component appears in the DOM and is interactive. Inspect
window.checkoutin the console; it should be the container object withinitandgetmethods.
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.