[Bug]: ModuleIdx is assigned in task-completion order, so identical builds produce different output
Reproduction
https://github.com/y0ngha/rolldown-nondeterministic-output
Zip snapshot: https://github.com/y0ngha/rolldown-nondeterministic-output/releases/download/v1/rolldown-nondeterministic-output.zip
git clone https://github.com/y0ngha/rolldown-nondeterministic-output && cd rolldown-nondeterministic-output
pnpm install
# A) no "sideEffects" in package.json
node -e "const f='package.json',j=require('./'+f);delete j.sideEffects;require('fs').writeFileSync(f,JSON.stringify(j,null,2))"
./run.sh 12
# 12 main-<hashA>.js <- one output, 12 times
# B) "sideEffects": ["*.css","*.scss"]
node -e "const f='package.json',j=require('./'+f);j.sideEffects=['*.css','*.scss'];require('fs').writeFileSync(f,JSON.stringify(j,null,2))"
./run.sh 12
# 7 main-<hashA>.js
# 5 main-<hashB>.js <- same input, two different outputsrun.sh runs rm -rf dist .vite && vite build N times and counts the output filenames.
Nothing else changes between runs - same files, same lockfile, same machine.
Reproduced on rolldown 1.0.3 (vite 8.0.16) and 1.2.8 (vite 8.3.0).
sideEffects is the switch that makes this visible in the reduced case, not the cause.
Confirming the cause without a patch
When the native binding loads, rolldown builds its own tokio runtime
(crates/rolldown_binding/src/lib.rs) with two pools: ROLLDOWN_WORKER_THREADS (async workers,
default num_cpus::get_physical() * 3 / 2) and ROLLDOWN_MAX_BLOCKING_THREADS (the
spawn_blocking pool, default 4). Completions from both land in the same ModuleLoaderMsg
channel, and ModuleIdx is handed out in arrival order. Pinning both pools to one thread
removes the race, and the output stops moving:
| 12 builds of the same input | distinct outputs |
|---|---|
| defaults | 7 : 5 |
ROLLDOWN_WORKER_THREADS=1 |
9 : 3 |
ROLLDOWN_MAX_BLOCKING_THREADS=1 |
6 : 6 |
both set to 1 |
12 / 12 identical |
Pinning one pool is not enough, because the other keeps racing. This is a diagnostic rather than a workaround, since a single-threaded build gives up the parallelism.
Why there is no REPL link
Two reasons, both in the source:
- That runtime block is
#[cfg(not(target_family = "wasm"))], androlldown_utils/src/rayon.rsreplaces rayon with a sequential shim undertarget_family = "wasm". The browser build is therefore always in the "both pools at 1" state above, where this bug does not appear. @rolldown/browserhas no npm resolution, and the ~430-module subgraph that flips is mostly dependency code, so the reproduction does not fit a REPL file map.
A REPL link would show a clean, deterministic bundle and read as "not reproducible". The
repository is a plain pnpm install plus vite build in a loop instead, with no native
toolchain or patched binary needed to see the split.
What is expected?
Two builds of the same input produce byte-identical output.
What is actually happening?
The builds differ in which modules survive tree-shaking, not only in hashes. Chunk
metadata from generateBundle over 8 builds of the reduced case:
modules per build: [436, 436, 5, 5, 436, 5, 436, 5]One variant keeps a ~430-module subgraph (an api-client chain and its vendor dependencies); the other drops it and keeps ~5. Both builds succeed.
Root cause
ModuleIdx is handed out while handling task-completion messages, which arrive in whatever
order parallel module tasks happen to finish:
// crates/rolldown/src/module_loader/module_loader.rs
while self.remaining > 0 {
let Some(msg) = self.rx.recv().await else { break }; // parallel completion order
ModuleLoaderMsg::NormalModuleDone(..) => {
... try_spawn_new_task(..) -> alloc_ecma_module_idx() // index assigned here
}
}Link-stage passes then walk the module table by index, so that timing reaches the output. Two we traced end to end:
tree_shaking/determine_side_effects.rs - a re-entrant visit (SideEffectCache::Visited)
returns the module's not-yet-finalized verdict, so the module the walk starts from decides the
result for shared or circular dependencies.
determine_module_exports_kind.rs - an ExportsKind::None importee is promoted by
whichever importer is visited first (import → Esm, require → CommonJs). The existing
comment says as much:
// the "earlier importer's promotion is observed by later importers" semantics.That exports_kind decides the __toESM(mod, isNodeMode) flag, so emitted code flips between:
var i = n(d(), 1), h = n(x(), 1)
var i = n(d()), h = n(x())isNodeMode is not cosmetic - it selects
isNodeMode || !mod || !mod.__esModule ? wrap-as-default : keep, so an unstable value can
change what import X from 'cjs-pkg' resolves to at runtime.
Fixing individual passes does not help
| attempt | our app (5759 files, 3 builds) |
|---|---|
| stock 1.2.8 | 848 / 896 / 875 files changed |
sort those two passes by stable_id |
852 / 964 / 972 |
| #10893 (worklist side-effect propagation) | 908 / 919 / 955 |
#10893 is worth calling out since it rewrites one of the affected passes. It fixes a
correctness bug (a module in a cycle marked side-effect free before the effect is found),
not this one: with it applied on top of v1.2.8 the reduced repro still splits. A pass
can compute a correct verdict and the build can still differ, because the walk it is handed
depends on task timing - and it does not touch determine_module_exports_kind, which produces
the unstable __toESM flag. The two are complementary and apply cleanly together.
(Measured on our app, where a run takes ~40s and the sample is stable. The reduced repro shows the same behaviour but with a smaller sample per run.)
Every pass consumes the same nondeterministic index, so fixing one moves the symptom instead of removing it.
Ordering index assignment does
Handling completions one wave at a time, ordered by module id, while tasks keep running in parallel:
| reduced repro | our app | |
|---|---|---|
| with that change | 12 / 12 identical | 4 / 4 builds byte-identical (5761 files) |
Both symptoms disappear together - the tree-shaking flip and the __toESM flag flip - which
is what identifies index assignment as the shared cause. Draft PR: https://github.com/rolldown/rolldown/pull/10910
Impact
In our production app (~4600 chunks, vite 8.0.16), ~25% of chunk filenames changed on every
clean build of the same commit (1191/4603). A single flipped module changes one chunk's
bytes, which shifts entries in Vite's __vitePreload dependency arrays, which changes each
importer - so a handful of unstable modules fan out to more than a thousand renamed chunks.
No CDN or browser keeps a warm copy across deployments.
We ruled out our own setup first: removing panda (styled-system), the design system, redux,
every custom vite plugin, a build-timestamp define, and cutting a 577-module import cycle -
cumulatively - left the variation unchanged (1169 → 1222 files changed).
System Info
System:
OS: macOS 26.2
CPU: (10) arm64 Apple M5
Memory: 32.00 GB
Binaries:
Node: 22.19.0
pnpm: 11.24.0
npmPackages:
rolldown: 1.0.3 (via vite 8.0.16), 1.2.8 (via vite 8.3.0)Any additional comments?
AI usage disclosure. Per the contribution guide's AI Usage Policy: I used Claude Code while investigating this. The elimination passes, the build-to-build diffing and the reduction of the reproduction were run as scripted experiments with its help, and it drafted this report. Every number quoted here comes from a build I ran and checked myself, the root cause was confirmed by reading the source and by instrumenting a local debug build, and the linked fix was built and measured before being written up. I take responsibility for the content.
The reduced repository preserves the module-graph shape of the original app - 32 source files, 35 runtime import edges, no cycles. Internal names were mechanically renamed and i18n data replaced with placeholders. Synthetic graphs of the same shape did not reproduce it, which is why this is a reduction of a real app rather than a generated fixture.
Source: rolldown/rolldown