Eager vs Lazy Shared Module Loading #
eager: true is the setting most often changed to make an error go away and least often understood — this page explains what it actually does, which side of the graph should use it, and why the eager-consumption error is usually telling you something else entirely.
The short version: exactly one participant should register a shared dependency eagerly, every other side should request it lazily, and the famous “Shared module is not available for eager consumption” error is almost never fixed by setting eager: true. It extends managing shared dependencies at runtime.
Prerequisites #
- Webpack 5 Module Federation with a
sharedblock on host and remotes. - An async bootstrap boundary in each entry, as in step-by-step Webpack 5 container configuration.
- Access to each build’s output, so you can confirm what actually landed in the entry chunk.
Step 1 — Understand what eager actually changes #
eager decides whether a shared module is included in the initial chunk or in a separate chunk loaded on demand.
With eager: false — the default — webpack emits a consume-shared reference and a fallback chunk. At runtime the module asks the share scope for the dependency; only if the scope cannot satisfy it does the fallback chunk load. That request is asynchronous, which is why the entry must not evaluate shared modules synchronously.
With eager: true, the dependency is compiled into the initial chunk and registered into the share scope as soon as the container initialises. There is no asynchronous step and no fallback chunk.
The consequence follows directly: eager registration makes the dependency available to everyone immediately and puts its full weight in the entry. Lazy consumption keeps the entry small and requires that nothing touches the dependency before the scope is initialised.
Step 2 — Assign eager by role, not by trial and error #
There is a correct answer, and it depends on what the build is.
The host — the shell that boots the application — should register shared dependencies eagerly. It is the first code to run, it is going to load React regardless, and eager registration means the scope is populated before any remote asks.
Remotes consumed by a host should be lazy. They request from the scope and normally receive the host’s copy, so shipping their own would be paying twice for something they will not use.
// host/webpack.config.js — the host owns the registration
shared: {
react: { singleton: true, requiredVersion: deps.react, eager: true },
'react-dom': { singleton: true, requiredVersion: deps['react-dom'], eager: true },
}
// remote/webpack.config.js — remotes consume, they do not register
shared: {
react: { singleton: true, requiredVersion: deps.react, eager: false },
'react-dom': { singleton: true, requiredVersion: deps['react-dom'], eager: false },
}
A remote that also runs standalone still uses eager: false. Its fallback chunk covers the standalone case — that is exactly what the fallback exists for — and it is not downloaded when a host has already registered a satisfying version.
Step 3 — Fix eager consumption at its actual cause #
The error reads: Shared module is not available for eager consumption. It means something imported a shared module synchronously from the entry, before the share scope was initialised.
Setting eager: true makes it disappear by removing the asynchronous step, which is why it is such a tempting fix. It also ships that dependency in every remote’s entry, which is the duplication you configured sharing to avoid.
The correct fix is the async boundary:
// src/index.js — this file imports nothing else
import('./bootstrap');
// src/bootstrap.js — everything real lives here
import React from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
createRoot(document.getElementById('root')).render(<App />);
The dynamic import creates a chunk boundary. Webpack initialises the share scope, then evaluates bootstrap.js, by which point the shared modules resolve normally. Two files, no configuration change, and the entry stays small.
Step 4 — Find the import that is still synchronous #
When the error persists despite an async boundary, something is reaching a shared module before it.
Common culprits: a polyfill import at the top of the entry, a side-effecting CSS-in-JS setup, an analytics snippet importing a shared utility, or a setupTests-style file accidentally included in the production entry.
// Find which module pulled the shared dependency into the initial chunk.
const stats = JSON.parse(readFileSync('stats.json', 'utf8'));
const initial = stats.chunks.filter((c) => c.initial);
for (const chunk of initial) {
for (const mod of chunk.modules ?? []) {
if (!mod.name?.includes('node_modules/react/')) continue;
console.log('reached from:', mod.reasons?.map((r) => r.moduleName).join(', '));
}
}
The reasons array names the importer, which is usually enough to identify the file. Move that import inside bootstrap.js and the error resolves without touching the shared configuration.
Step 5 — Recognise the cases where eager is genuinely right #
There are three, and they are narrower than the setting’s popularity suggests.
A host that is the sole entry point, as in step 2 — the standard case.
A dependency that must be present before any application code runs, such as a polyfill that patches a global. There is no way to await a share scope before a polyfill applies, so eager is the only option; the cost is that every side shipping it eagerly ships a copy.
A remote deployed standalone as its own application, where there is no host to register anything. Note that this is a property of the deployment, not the remote, which is why the standalone entry rather than the exposed module is where it belongs.
// A polyfill that must apply before anything else. Eager on every side.
shared: {
'core-js': { singleton: true, eager: true, requiredVersion: deps['core-js'] },
}
Step 6 — Confirm what the build actually produced #
The configuration states intent; the output is the fact.
# A lazily-consumed dependency should NOT be in the initial chunk.
npx webpack --json > stats.json
node -e "
const s = require('./stats.json');
const initial = s.chunks.filter(c => c.initial).flatMap(c => c.modules ?? []);
const bundled = initial.filter(m => /node_modules\/react\//.test(m.name ?? ''));
console.log(bundled.length ? 'react is EAGER in the initial chunk' : 'react is lazily consumed');
"
Run this in each remote’s CI. A remote that silently flips to eager — because someone added an import, or changed the configuration to clear an error — is a duplication regression that no size budget on its own will explain, and it is the assertion described in analyzing federated bundles with webpack-bundle-analyzer.
Step 7 — Understand the interaction with singleton and strictVersion #
The three settings are independent and are routinely confused.
singleton: true means only one instance may be used, and a mismatch produces a warning rather than a second copy. strictVersion: true upgrades that mismatch from a warning to an error. eager decides only when the module is loaded, not how many copies exist.
The combination worth defaulting to for framework dependencies is singleton: true, strictVersion: true, with eager set by role. That gives one instance, a loud failure if versions cannot be reconciled, and no duplicated weight in remote entries.
// The framework contract, expressed once and mirrored everywhere.
const framework = (version, eager) => ({
singleton: true, requiredVersion: version, strictVersion: true, eager,
});
// host: shared: { react: framework(deps.react, true) }
// remote: shared: { react: framework(deps.react, false) }
Sharing this helper from one internal package is worth doing. The failures in this area come overwhelmingly from configurations that were meant to match and quietly diverged.
Verification #
- The host registers eagerly: React should be in the host’s initial chunk.
- Remotes do not: React should appear only as a
consume-sharedreference plus an unused fallback chunk. - The fallback is not fetched: on a composed page, no remote should download its React fallback chunk.
- One version at runtime: the share scope should list exactly one version of each shared dependency.
- The error is genuinely fixed: confirm the entry file contains only the dynamic import, and that removing
eager: truefrom a remote does not bring the error back.
Troubleshooting #
Symptom: eager consumption error despite an async bootstrap.
Diagnosis: another import in the entry reaches a shared module first. Fix: use the stats analysis in step 4 to name the importer, then move it inside the bootstrap module.
Symptom: every remote entry is large and contains React.
Diagnosis: eager: true was set on remotes to clear an error. Fix: restore the async boundary, set remotes back to lazy, and assert on the initial chunk in CI.
Symptom: a remote works standalone and fails inside the host.
Diagnosis: the standalone entry’s eager registration is leaking into the federated build. Fix: keep the standalone entry out of exposes and out of the federated entry chunk.
Symptom: the fallback chunk downloads on a composed page.
Diagnosis: negotiation failed, so the remote fell back to its own copy. Fix: align versions and enable strictVersion so the mismatch fails loudly instead of silently duplicating.