Rollup Chunk Assignment: Default Algorithm and Config Impact (Source-Level Analysis)
Overview
This article focuses on Rollup's chunk assignment algorithm, dynamic entry optimization, and configuration impact scope in the default multi-chunk path, with reproducible conclusions and verification paths grounded in source code.
This article does not cover chunk naming or output path rules (e.g.
chunkFileNames). It only covers factors that change chunk assignment results.
Reproducible baseline (version and scope)
- Source baseline (current Rollup repo commit):
c79e6c201d1f99e126d2e6bfb3f8c5c100ddcebf - Key files:
src/Bundle.tssrc/utils/chunkAssignment.ts
Conclusions are strictly based on the version above and are not guaranteed to apply to other versions or branches.
Scope and default assumptions
This article discusses the default multi-chunk path:
output.inlineDynamicImports = falseoutput.preserveModules = falseoutput.manualChunksnot used (or not affecting this case)
If you enable inlineDynamicImports / preserveModules / manualChunks, the flow and results change (see "Config impact" below).
Core concepts (aligned with source)
- entry: the static entry module from Rollup
input. - dynamic entry: a dynamically imported target module; added to
allEntriesduring chunking. - dependent entries: which entries can reach a module statically.
- atom: a set of modules with the same dependent entries.
- staticDependencyAtomsByEntry: atoms statically reachable from an entry (BigInt bitset).
- alreadyLoadedAtomsByEntry: atoms that are "guaranteed in memory" when an entry is loaded (BigInt bitset).
Default chunk assignment algorithm (execution order)
Default Chunking Flow
EXEC1) Entry path selection (Bundle-level branch)
Bundle.generateChunks calls getChunkAssignments in the default path.
const executableModule = inlineDynamicImports
? [{ alias: null, modules: includedModules }]
: preserveModules
? includedModules.map(module => ({ alias: null, modules: [module] }))
: getChunkAssignments(
this.graph.entryModules,
manualChunkAliasByEntry,
experimentalMinChunkSize,
this.inputOptions.onLog,
typeof manualChunks === 'function',
onlyExplicitManualChunks
);- Location:
src/Bundle.ts:158-192
2) manualChunks pre-allocation
If manualChunks is configured, it first builds chunkDefinitions and marks modulesInManualChunks. The default algorithm then skips those modules.
if (isManualChunksFunctionForm && onlyExplicitManualChunks) {
(manualChunkModulesByAlias[alias] ||= []).push(entry);
} else {
addStaticDependenciesToManualChunk(
entry,
(manualChunkModulesByAlias[alias] ||= []),
modulesInManualChunks
);
}- Location:
src/utils/chunkAssignment.ts:222-267
3) Graph construction and dependent entries
Walk entry modules and static deps to record dependent entries for each module. Dynamic import targets are added to allEntries.
getOrCreate(dependentEntriesByModule, module, getNewSet<number>).add(entryIndex);
for (const dependency of module.getDependenciesToBeIncluded()) {
if (!(dependency instanceof ExternalModule)) {
staticDependencies.add(dependency);
}
}
for (const { node: { resolution } } of module.dynamicImports) {
if (
resolution instanceof Module &&
resolution.includedDynamicImporters.length > 0 &&
!allEntriesSet.has(resolution)
) {
dynamicEntryModules.add(resolution);
allEntriesSet.add(resolution);
dynamicImportsForCurrentEntry.add(resolution);
}
}- Location:
src/utils/chunkAssignment.ts:269-357
4) Atom grouping
Modules with the same dependent entries are merged into atoms.
let chunkSignature = 0n;
for (const entryIndex of dependentEntries) {
chunkSignature |= 1n << BigInt(entryIndex);
}
(chunkModules[String(chunkSignature)] ||= {
dependentEntries: new Set(dependentEntries),
modules: []
}).modules.push(...modules);- Location:
src/utils/chunkAssignment.ts:436-450
5) Dynamic entry "already-loaded atoms" optimization
Compute atoms that are guaranteed to be in memory when a dynamic entry loads, and remove the corresponding dependent entry markers (see "Proof flow" below).
- Intersection and iteration logic:
src/utils/chunkAssignment.ts:491-533 - Marker removal logic:
src/utils/chunkAssignment.ts:536-559
6) Re-cluster to produce initial chunks
After updating dependent entries, re-cluster to produce initial chunks.
- Location:
src/utils/chunkAssignment.ts:562-627
7) experimentalMinChunkSize merge optimization
If a minimum chunk size threshold is enabled, Rollup attempts to merge small chunks while respecting side-effect and dependency constraints.
- Location:
src/utils/chunkAssignment.ts:740-769
For the full merge algorithm analysis (greedy loop, side-effect constraints, cycle checks, etc.), see experimentalMinChunkSize.
Proof flow for dynamic entry optimization (core logic)
Goal: prove that "compute already-loaded atoms for a dynamic entry D and remove dependent entry markers" is correct and safe.
1) Dynamically dependent entry set for a dynamic entry
For a dynamic entry D, its dynamically dependent entries are the union of dependent entries for all dynamic importers (and implicitlyLoadedAfter).
- Location:
getDynamicallyDependentEntriesByDynamicEntrysrc/utils/chunkAssignment.ts:410-433
Let Dep(D) denote this entry set.
2) Key proposition (already-loaded atoms)
For any dynamic entry D, the set of atoms guaranteed to be loaded when D is loaded equals:
⋂_{e ∈ Dep(D)} ( staticDependencyAtomsByEntry[e] ∪ alreadyLoadedAtomsByEntry[e] )Rationale:
- For any importer
e,staticDependencyAtomsByEntry[e]is guaranteed to be in memory afterefinishes loading. - If
eitself is a dynamic entry, the atoms guaranteed to be in memory before it loads arealreadyLoadedAtomsByEntry[e]. - "Guaranteed to be loaded" must hold for all importers, so take the intersection.
3) Iterative convergence (dynamic entries depending on dynamic entries)
The source computes a fixed point via iteration:
const alreadyLoadedAtomsByEntry: bigint[] = allEntries.map((_entry, entryIndex) =>
dynamicallyDependentEntriesByDynamicEntry.has(entryIndex) ? -1n : 0n
);
for (const [dynamicEntryIndex, dynamicallyDependentEntries] of dynamicallyDependentEntriesByDynamicEntry) {
dynamicallyDependentEntriesByDynamicEntry.delete(dynamicEntryIndex);
const knownLoadedAtoms = alreadyLoadedAtomsByEntry[dynamicEntryIndex];
let updatedLoadedAtoms = knownLoadedAtoms;
for (const entryIndex of dynamicallyDependentEntries) {
updatedLoadedAtoms &=
staticDependencyAtomsByEntry[entryIndex] | alreadyLoadedAtomsByEntry[entryIndex];
}
if (updatedLoadedAtoms !== knownLoadedAtoms) {
alreadyLoadedAtomsByEntry[dynamicEntryIndex] = updatedLoadedAtoms;
for (const dynamicImport of dynamicImportsByEntry[dynamicEntryIndex]) {
getOrCreate(
dynamicallyDependentEntriesByDynamicEntry,
dynamicImport,
getNewSet<number>
).add(dynamicEntryIndex);
}
}
}-1nmeans unknown (all bits are 1). Each iteration tightens the set.- If a dynamic entry's result changes, its dependent dynamic entries are re-marked for update until convergence.
4) Correctness of removing dependent entry markers
If an atom A satisfies:
A ∈ alreadyLoadedAtomsByEntry[D]then A is in memory for any path that triggers D. Removing the D marker on A will not cause missing loads and instead avoids redundant shared chunks.
Corresponding source:
if (
(alreadyLoadedAtomsByEntry[entryIndex] & chunkMask) === chunkMask &&
(awaitedAlreadyLoadedAtomsByEntry[entryIndex] & chunkMask) === 0n
) {
dependentEntries.delete(entryIndex);
}Reproducible example (entry => b; entry -> s; b -> s)
1) dependent entries
entry->b->s->
2) Initial atoms
{E}:entry{B}:b{E,B}:s
3) Already-loaded atoms
- The importer of
bisentry entry's statically reachable atoms includeentryands- =>
alreadyLoadedAtoms[b]includes{E}and{E,B}
4) Remove markers and final result
s's dependent entries change from{E,B}->{E}- After re-clustering:
{E}->entry + s{B}->b
Conclusion: s no longer goes into the dynamic entry chunk and is merged into the static entry side.
Config options that affect chunk assignment (scope / timing / effect)
Only lists config options that change chunk assignment results.
Documentation Note
onlyExplicitManualChunks was introduced in Rollup 4 and is marked as deprecated in the official docs (Rollup 5 is expected to change the default behavior of the function form to "explicit modules only"). This note comes from the official docs, not from source comments.
Important boundary conditions
awaited dynamic imports: Dynamic imports related to
top-level awaitwill not have their markers removed, to avoid creating circular dependencies in output chunks (the second condition inremoveUnnecessaryDependentEntries).manualChunks and
experimentalMinChunkSize:experimentalMinChunkSizeonly applies to automatic chunks and does not rewrite manual chunks.preserveModulesandinlineDynamicImports: Both bypass the default chunk assignment algorithm, so dynamic entry optimization and minChunkSize merges do not apply.
Reproducible verification path (recommended)
- Locate
Bundle.generateChunksin Rollup source to confirm the branch path and parameter passing. - Step through in
chunkAssignment.ts:analyzeModuleGraphgetChunksWithSameDependentEntriesgetAlreadyLoadedAtomsByEntry/removeUnnecessaryDependentEntriesgetOptimizedChunks
- Validate dependent entries and final chunk assignment results using the dependency graph in this article.
Conclusion (objective statement)
Rollup's default multi-chunk assignment algorithm first creates atom chunks based on dependent entries, then removes dependent entry markers via the dynamic entry "already-loaded atoms" optimization to avoid redundant shared chunks. It then applies experimentalMinChunkSize merge optimization under side-effect and dependency constraints. Different config options change the module set or chunking rules at different phases, resulting in different final chunk structures.