- Published on
Finding a Redundant Traversal in Webpack With CPU Profiling
- Authors

- Name
- Shashank Upadhyay
Introduction
After profiling a production build on a large Next.js project with Module Federation, the bottom-up view pointed to moduleGraph.isDeferred() inside ConcatenatedModule._getModulesWithInfo(). The function was invoked thousands of times per build. Every call returned false — but only after walking the module's full incoming connection list.
The project never enabled experiments.deferImport. The traversal was provably redundant.
This post traces the code path, explains why it is expensive at scale, and covers the two-line fix that landed as webpack#21096 — now shipped in webpack@5.108.0.
- The Symptom
- What the Profile Showed
- The Code Path: Three Pillars
- Why isDeferred() Is Expensive at Scale
- The Inconsistency
- The Fix
- Upstream Merge
- Results
- Takeaways
- Closing
The Symptom
The project uses Module Federation with roughly ten remotes. Each remote is registered via ContainerReferencePlugin → ExternalsPlugin, creating an ExternalModule per remote in the webpack module graph.
Build time had grown by roughly nine minutes. The stall sat between consecutive optimizeChunkModules hooks — inside webpack's seal phase, where ConcatenatedModule generates code for scope-hoisted bundles.
The before CPU profile showed a sustained ~2 minute gap in that window. Bottom-up analysis attributed it to ModuleGraph.isDeferred() and getIncomingConnections().
What the Profile Showed
isDeferred() checks whether a module uses TC39's deferred import evaluation. That is a real feature — but only when experiments.deferImport is enabled.
Our build never set that flag. Yet webpack spent minutes in the check. The profile gave a precise call site; reading the source explained why.

The cpupro call tree view shows isDeferred being called repeatedly from _getModulesWithInfo in ConcatenatedModule.js (lines 2155, 2146) during codeGeneration. The ancestor call sites confirm the pattern: every code generation pass triggers the traversal.
The Code Path: Three Pillars
Three independent webpack mechanisms intersect at the call site:

Pillar 1: ExternalsPlugin creates ExternalModules
Module Federation passes remotes into ExternalsPlugin("script"). Each remote becomes an ExternalModule with externalType: "script" in the module graph.
Pillar 2: deferImport defaults to off
Webpack initialises experiments with deferImport: false:
// lib/config/defaults.js
D(experiments, "deferImport", false);
HarmonyImportDependencyParserPlugin uses createGetImportPhase(deferImport=false), which always returns ImportPhase.Evaluation. No dependency in the graph ever gets phase = ImportPhase.Defer.
ImportPhaseUtils.isDefer(phase) can never return true. Any isDeferred() call when the experiment is off is guaranteed to return false — but only after traversing incoming connections.
Pillar 3: ModuleConcatenationPlugin scope-hoists into ConcatenatedModule
When optimization.concatenateModules is enabled, ModuleConcatenationPlugin merges modules into ConcatenatedModule instances. Modules not eligible for inner scope-hoisting — including ExternalModule remotes — remain as type: "external" entries in the concatenation list.
During compilation.codeGeneration(), each ConcatenatedModule calls _getModulesWithInfo():
// lib/optimize/ConcatenatedModule.js — simplified
case "external":
item = {
// ...
deferred: moduleGraph.isDeferred(info.module), // unguarded
};
Separately, updateHash() hits the same check:
chunkGraph.moduleGraph.isDeferred(info.module) ? "1" : "0"
Why isDeferred() Is Expensive at Scale
isDeferred() is not a flag read. It walks the module's incoming connection list:
isDeferred(module) {
if (this.isAsync(module)) return false;
const connections = this.getIncomingConnections(module);
for (const connection of connections) {
if (!(connection.dependency instanceof HarmonyImportDependency)) continue;
if (ImportPhaseUtils.isDefer(connection.dependency.phase)) return true;
}
return false;
}
The time saved by guarding this call is:
T_saved = (number of isDeferred() calls) × (avg cost per call)
Where the average cost per call is proportional to the module's incoming connection count. Each call walks the full connection list before returning false.
With externals — including federated remotes, shared libraries like React, and design system packages imported from outside the bundle — spread across many ConcatenatedModule instances, the traversal runs thousands of times per build. In practice, the majority of externals are shared libraries (React, React DOM, UI component libraries, etc.) rather than remotes. Every invocation returns false.
The Inconsistency
ModuleConcatenationPlugin already guarded its own isDeferred() call:
// lib/optimize/ModuleConcatenationPlugin.js
const deferEnabled = compilation.options.experiments.deferImport;
if (deferEnabled && moduleGraph.isDeferred(module)) {
setInnerBailoutReason(module, "Module is deferred");
canBeInner = false;
}
The same deferImport feature introduced calls in ConcatenatedModule without the equivalent guard. The pattern existed in one file and was missing in the other.
The Fix
Apply the same guard at both call sites in ConcatenatedModule:
_getModulesWithInfo():
// before
deferred: moduleGraph.isDeferred(info.module),
// after
deferred: this.compilation.options.experiments.deferImport
? moduleGraph.isDeferred(info.module)
: false,
updateHash():
// before
chunkGraph.moduleGraph.isDeferred(info.module) ? "1" : "0"
// after
(this.compilation.options.experiments.deferImport &&
chunkGraph.moduleGraph.isDeferred(info.module)) ? "1" : "0"
Correctness
When deferImport is false: the guard short-circuits to false. Semantically identical — the parser never emitted a deferred-phase dependency.
When deferImport is true: behaviour is unchanged. The guard passes through to isDeferred() exactly as before.
Cache invalidation note: The updateHash() fix changes the hash string format for external modules (from always calling isDeferred() to returning "0" directly when deferImport is off). This means upgrading to webpack >= 5.108.0 will produce different content hashes for chunks containing externals, invalidating filesystem caches from previous builds. Your first build after upgrading will be a full rebuild, but subsequent builds will benefit from both the performance fix and normal caching. The _getModulesWithInfo() fix alone accounts for the bulk of the performance regression.
Upstream Merge
The PR webpack#21096 was merged and is included in webpack v5.108.0 and later releases under patch changes:
perf: guard isDeferred() behind experiments.deferImport in ConcatenatedModule (by @shashank-u03 in #21096)
If you are on webpack >= 5.108.0, you already have the fix. Upgrade and re-profile to confirm the hotspot is gone.
Results
After applying the fix, total production build time dropped by roughly 11 minutes (~32%).
Before the fix

isDeferredself-time: 450,113ms (20.3%) — the top hotspot- webpack package: 883,568ms self-time (39.9%)
After the fix

isDeferred: no longer appears in the top self-time entries- webpack package: 388,059ms self-time (24.5%)
The after profile shows isDeferred() and getIncomingConnections() no longer ranking in the bottom-up self-time view during code generation. The guard short-circuits before the traversal begins.
Takeaways
- Default-off experiments can still impose default-on costs — guard every call site, not just the plugin that owns the feature
- Look for guard inconsistencies — when a feature spans multiple files, check that each call site follows the same pattern
- CPU profiling makes upstream fixes possible — the fix is two conditionals; the profile is what made them findable
Closing
If your webpack build uses externals — federated remotes, CDN scripts, Node.js builtins inside concatenated scopes — and build time has grown quietly, profile it. The bottleneck may be in webpack core, not your application code.
For the profiling workflow itself, see How to Identify Build-Time Bottlenecks Using CPU Profiling.