🐛 bug report
I am working with parcel on a rescript project in a big ol' monorepo. We've had intermittent parcel build failures since time immemorial. The fix we've used for years is just to restart parcel and nuke the cache. This works, it slows things down and sometimes it takes you a minute to notice. My assumption has been that that this has to do with race conditions watching the massive churn of generated js files from the rescript compiler.
The below is from a session with claude code where it isolated the issue, first in my repo and then again a test in repo with the parcel testing toolchain installed. Anyway, first time pull-requester long-time-user and thanks to the maintainers for all the work they do. Below from the maw of the machine:
Watcher race: an external process bulk-writing files mid-build crashes parcel serve with Got unexpected null at nullthrows(bundle.name) after BundleGraphRequest aborts.
When parcel serve (or any watch-mode build) is bundling and the watcher delivers an FS event before the bundler finishes naming bundles, the in-flight BundleGraphRequest aborts inside its try block, the catch path (added in #9366 for failure-replay diagnostics) caches the partially-constructed BundleGraph, and every subsequent build that hits getPreviousResult() reuses that partial graph — skipping nameBundle, applyRuntimes, and validateBundles. Downstream packaging then explodes the first time anything reads bundle.name.
Triggering scenario: a separate process (for us a ReScript compiler, but any external compiler that emits a burst of files into a watched directory has the same effect) writes many .js files in quick succession while Parcel is building.
🎛 Configuration (.babelrc, package.json, cli command)
No .babelrc, no .parcelrc. Minimal package.json for the synthetic repro:
{
"name": "parcel-race-repro",
"private": true,
"dependencies": { "parcel": "^2.16.4" }
}
CLI:
parcel serve --no-hmr --no-source-maps --port 9991 \
--log-level verbose --cache-dir .parcel-cache ./index.html
🤔 Expected Behavior
While parcel serve is mid-build, an external process writes a burst of .js files into a watched directory. Either:
- The current build completes and the next one picks up the changes, OR
- The current build is aborted (legitimate
BuildAbortError) and the next build runs from scratch and succeeds.
Subsequent builds should not crash with nullthrows-class errors that have nothing to do with the user's source.
😯 Current Behavior
Subsequent builds crash repeatedly with Got unexpected null at bundle.name reads:
🚨 Build failed.
Error: Got unexpected null
at nullthrows /node_modules/@parcel/core/lib/PackagerRunner.js:152
(same surface also fires at PackagerRunner.js:634, :738; BundleGraphRequest.js:480)
Older 2.16.1 also surfaced Worker send back a reference to a missing dev dep request and Expected content key <16-hex> to exist; on 2.16.4 the bundle.name=null surface is the dominant one. Mitigation matrix from a 45-second storm (100 files / 50 ms, truncate mode, Parcel 2.16.4):
| Configuration |
Build failures |
Got unexpected null |
| Baseline (FSEvents, default workers) |
94 |
176 |
--watch-backend watchman |
similar (236 on 2.16.1) |
— |
PARCEL_WORKERS=0 (single-process) |
112 (2.16.1) / similar |
— |
--watch-ignore <dir> |
83 (2.16.1) |
— |
Critical: PARCEL_WORKERS=0 only halves the failure count and does not eliminate it. That rules out the worker-IPC race chased by #9532 / #9636. The remaining failures are a main-process async-reentrancy bug: a watcher callback runs on a microtask between the build loop's await points and mutates state the loop is iterating.
💁 Possible Solution
Root cause (verified locally with code instrumentation)
In packages/core/core/src/requests/BundleGraphRequest.js:
BundleGraphRequest.run constructs internalBundleGraph via InternalBundleGraph.fromAssetGraph(...), then await bundler.bundle(...). Bundles exist but are unnamed.
- If an abort signal fires during the surrounding
await runDevDepRequest(...) (or any nested await that bottoms out in assertSignalNotAborted(this.signal)), BuildAbortError propagates up.
- The
catch (e) block (added by #9366 for failure-replay diagnostics) caches the partial graph via this.api.storeResult({bundleGraph: internalBundleGraph, ...}, this.cacheKey) and re-throws.
- The next build re-enters
BundleGraphRequest.run. previousBundleGraphResult = await this.api.getPreviousResult() returns the partial graph stored in step 3. Since previousBundleGraphResult != null, the code reuses it and skips the entire if (!previousBundleGraphResult) { nameBundle … applyRuntimes … validateBundles … } block.
WriteBundlesRequest fans out PackageRequests. PackagerRunner.loadConfig runs let name = nullthrows(bundle.name) → 💥.
Confirmation logs from a 45-second run with verifying instrumentation:
[BGR:catch:storePartial] {bundles:2, namedBundles:0, unnamedBundles:2, cacheKey:"70dba4b52c152bd4-BundleGraph"}
[BGR:reuse:UNNAMED] {bundles:2, unnamed:2, cacheKey:"70dba4b52c152bd4-BundleGraph"} ← 114 occurrences
[bundleNameNull] {bundleId:"89d10fc6ef6ce18a", bundleType:"html", nameValue:"null"} ← every subsequent build
One catch-path storage spawns dozens of follow-on builds that all reuse the unnamed graph until either the cache key changes or the storm subsides.
Proposed fix
Smallest viable change (~19 lines including comments): tag the cached-on-error result with incomplete: true and reject it in the reuse check. Preserves #9366's diagnostic intent (the partial graph is still cached for failure analysis) while preventing accidental reuse as an incremental baseline.
--- a/packages/core/core/src/requests/BundleGraphRequest.js
+++ b/packages/core/core/src/requests/BundleGraphRequest.js
type BundleGraphRequestResult = {|
bundleGraph: InternalBundleGraph,
+ incomplete?: boolean,
|};
export type BundleGraphResult = {|
bundleGraph: InternalBundleGraph,
changedAssets: Map<string, Asset>,
assetRequests: Array<AssetGroup>,
+ incomplete?: boolean,
|};
…
if (graph.safeToIncrementallyBundle) {
try {
previousBundleGraphResult = await this.api.getPreviousResult();
} catch { /* … */ }
}
+ if (previousBundleGraphResult?.incomplete) {
+ previousBundleGraphResult = null;
+ }
if (previousBundleGraphResult == null) {
graph.safeToIncrementallyBundle = false;
}
…
} catch (e) {
if (internalBundleGraph != null) {
this.api.storeResult(
{
bundleGraph: internalBundleGraph,
changedAssets: new Map(),
assetRequests: [],
+ incomplete: true,
},
this.cacheKey,
);
}
throw new ThrowableDiagnostic({ /* … */ });
On the synthetic repro this drops Got unexpected null from 176 to 0 and total build failures from 94 to 7 — the residual 7 are the pre-existing BuildAbortError from the abort signal itself, not corrupted-graph crashes.
I have a branch staged with this change plus a deterministic regression test under packages/core/integration-tests/test/incremental-bundling.js (uses a sinon.stub on the default bundler to drive the same catch-path, so it doesn't depend on watcher timing in CI). The test fails without the fix (expected the recovered build to succeed) and passes with it. Happy to open the PR once this issue # is assigned.
🔦 Context
This bites real-world ReScript stacks because the ReScript compiler emits a burst of .bs.js files into the watched output directory whenever anything in the project recompiles. Every recompile triggers the race; the race fails 50–80% of the time (per the matrix above); each failure corrupts the in-memory BundleGraph cache for the next ~100+ builds until the cache key churns past it. Result: parcel serve becomes effectively unusable when ReScript is rebuilding.
The same pattern fires for any external code generator that writes to a watched directory in bursts: protoc, sqlc, GraphQL codegen, Tailwind CLI, etc. The synthetic repro below uses plain shell to remove ReScript from the picture.
Related issues / PRs:
💻 Code Sample
Self-contained, no plugins, no ReScript. Paste this single block into a terminal — it scaffolds a sandbox in $TMPDIR, runs parcel serve via npx (no install in your working tree), fires the storm for 15 s, prints the failure counts, and cleans up after itself. ~30 s end-to-end on a recent Mac.
bash <<'REPRO'
set -uo pipefail
DUR="${DUR:-30}"
DIR="$(mktemp -d -t parcel-race-XXXXXX)"
PARCEL_PID=""
cleanup() {
[ -n "$PARCEL_PID" ] && kill -INT "$PARCEL_PID" 2>/dev/null
[ -n "$PARCEL_PID" ] && wait "$PARCEL_PID" 2>/dev/null
cd /
rm -rf "$DIR"
}
trap cleanup EXIT
cd "$DIR"
mkdir -p gen
echo '{ "name": "parcel-race", "private": true }' > package.json
echo '<!doctype html><script type="module" src="./entry.js"></script>' > index.html
: > entry.js
for i in $(seq 0 99); do
printf 'export const v%d = %d;\n' "$i" "$i" > "gen/mod${i}.js"
printf 'import { v%d } from "./gen/mod%d.js";\n' "$i" "$i" >> entry.js
done
echo 'console.log(v0);' >> entry.js
echo "[parcel-race-repro] $DIR — starting parcel serve via npx..."
PORT="${PORT:-9991}"
npx -y parcel@2.16.4 serve --no-hmr --no-source-maps --port "$PORT" \
--log-level verbose --cache-dir .parcel-cache index.html > parcel.log 2>&1 &
PARCEL_PID=$!
# Wait for the first successful build, then start the storm. Bail if parcel exits early.
for _ in $(seq 1 120); do
grep -q "Built in" parcel.log 2>/dev/null && break
kill -0 "$PARCEL_PID" 2>/dev/null || { echo "✗ parcel exited before first build:"; tail -10 parcel.log; exit 1; }
sleep 0.5
done
grep -q "Built in" parcel.log 2>/dev/null || { echo "✗ first build did not land within 60s"; tail -10 parcel.log; exit 1; }
echo "[parcel-race-repro] first build landed; storming for ${DUR}s..."
END=$(( $(date +%s) + DUR ))
while [ "$(date +%s)" -lt "$END" ]; do
for i in $(seq 0 99); do
printf 'export const v%d = %d; // %d\n' "$i" "$i" "$RANDOM" > "gen/mod${i}.js"
done
sleep 0.05
done
sleep 1
NULL_HITS=$(grep -cE 'Got unexpected null' parcel.log)
BUILD_FAIL=$(grep -cE '🚨 Build failed' parcel.log)
LOG_COPY="${TMPDIR:-/tmp}/parcel-race-repro-$$.log"
cp parcel.log "$LOG_COPY"
echo ""
echo "=== parcel-race-repro results (${DUR}s storm) ==="
echo " Got unexpected null : $NULL_HITS"
echo " Build failed : $BUILD_FAIL"
echo " Full log preserved : $LOG_COPY"
if [ "$NULL_HITS" -gt 0 ]; then
echo "✗ Reproduced — saw $NULL_HITS null-deref crashes from a cached partial bundle graph."
else
echo "✓ No null-deref crashes observed."
fi
REPRO
Expected on a recent Mac with the default 30 s storm: tens of Build failed and dozens-to-hundreds of Got unexpected null lines (45 s scales linearly to the matrix figures above; we observed 40 / 23 in a 15 s run and 94 / 176 in a 45 s run). The race is timing-sensitive — if the first attempt shows zero crashes, re-run with DUR=60 bash <<'REPRO' … REPRO. The bug fires within ~1-2 s of the storm starting once parcel is actively bundling.
🌍 Your Environment
| Software |
Version(s) |
| Parcel |
2.16.4 (also reproduced on 2.16.1) |
| Node |
v22.22.3 (also v20) |
| npm/Yarn |
npm 10.x; underlying project uses Yarn 3.6.3 |
| Operating System |
macOS 15 (Darwin 25.4, arm64) |
Reproduced with parcel serve (watch mode). Watch backend: FSEvents (default); reproduces under --watch-backend watchman as well.
LLM Disclaimer Issue body drafted with AI assistance (Claude). I know there are many slop PRs out there. This is a real bug I've encountered and this fix seems to address it. While I have my reservations about LLM technology and its uses, this was something that would have taken me long enough to get moving on my local machine that I would have simply continued to suffer the problem (or submitted a much vaguer bug report).
🐛 bug report
I am working with parcel on a rescript project in a big ol' monorepo. We've had intermittent parcel build failures since time immemorial. The fix we've used for years is just to restart parcel and nuke the cache. This works, it slows things down and sometimes it takes you a minute to notice. My assumption has been that that this has to do with race conditions watching the massive churn of generated js files from the rescript compiler.
The below is from a session with claude code where it isolated the issue, first in my repo and then again a test in repo with the parcel testing toolchain installed. Anyway, first time pull-requester long-time-user and thanks to the maintainers for all the work they do. Below from the maw of the machine:
Watcher race: an external process bulk-writing files mid-build crashes
parcel servewithGot unexpected null at nullthrows(bundle.name)afterBundleGraphRequestaborts.When
parcel serve(or any watch-mode build) is bundling and the watcher delivers an FS event before the bundler finishes naming bundles, the in-flightBundleGraphRequestaborts inside itstryblock, thecatchpath (added in #9366 for failure-replay diagnostics) caches the partially-constructedBundleGraph, and every subsequent build that hitsgetPreviousResult()reuses that partial graph — skippingnameBundle,applyRuntimes, andvalidateBundles. Downstream packaging then explodes the first time anything readsbundle.name.Triggering scenario: a separate process (for us a ReScript compiler, but any external compiler that emits a burst of files into a watched directory has the same effect) writes many
.jsfiles in quick succession while Parcel is building.🎛 Configuration (.babelrc, package.json, cli command)
No
.babelrc, no.parcelrc. Minimalpackage.jsonfor the synthetic repro:{ "name": "parcel-race-repro", "private": true, "dependencies": { "parcel": "^2.16.4" } }CLI:
🤔 Expected Behavior
While
parcel serveis mid-build, an external process writes a burst of.jsfiles into a watched directory. Either:BuildAbortError) and the next build runs from scratch and succeeds.Subsequent builds should not crash with
nullthrows-class errors that have nothing to do with the user's source.😯 Current Behavior
Subsequent builds crash repeatedly with
Got unexpected nullatbundle.namereads:Older 2.16.1 also surfaced
Worker send back a reference to a missing dev dep requestandExpected content key <16-hex> to exist; on 2.16.4 thebundle.name=nullsurface is the dominant one. Mitigation matrix from a 45-second storm (100 files / 50 ms, truncate mode, Parcel 2.16.4):Got unexpected null--watch-backend watchmanPARCEL_WORKERS=0(single-process)--watch-ignore <dir>Critical:
PARCEL_WORKERS=0only halves the failure count and does not eliminate it. That rules out the worker-IPC race chased by #9532 / #9636. The remaining failures are a main-process async-reentrancy bug: a watcher callback runs on a microtask between the build loop'sawaitpoints and mutates state the loop is iterating.💁 Possible Solution
Root cause (verified locally with code instrumentation)
In
packages/core/core/src/requests/BundleGraphRequest.js:BundleGraphRequest.runconstructsinternalBundleGraphviaInternalBundleGraph.fromAssetGraph(...), thenawait bundler.bundle(...). Bundles exist but are unnamed.await runDevDepRequest(...)(or any nestedawaitthat bottoms out inassertSignalNotAborted(this.signal)),BuildAbortErrorpropagates up.catch (e)block (added by #9366 for failure-replay diagnostics) caches the partial graph viathis.api.storeResult({bundleGraph: internalBundleGraph, ...}, this.cacheKey)and re-throws.BundleGraphRequest.run.previousBundleGraphResult = await this.api.getPreviousResult()returns the partial graph stored in step 3. SincepreviousBundleGraphResult != null, the code reuses it and skips the entireif (!previousBundleGraphResult) { nameBundle … applyRuntimes … validateBundles … }block.WriteBundlesRequestfans outPackageRequests.PackagerRunner.loadConfigrunslet name = nullthrows(bundle.name)→ 💥.Confirmation logs from a 45-second run with verifying instrumentation:
One catch-path storage spawns dozens of follow-on builds that all reuse the unnamed graph until either the cache key changes or the storm subsides.
Proposed fix
Smallest viable change (~19 lines including comments): tag the cached-on-error result with
incomplete: trueand reject it in the reuse check. Preserves #9366's diagnostic intent (the partial graph is still cached for failure analysis) while preventing accidental reuse as an incremental baseline.On the synthetic repro this drops
Got unexpected nullfrom 176 to 0 and total build failures from 94 to 7 — the residual 7 are the pre-existingBuildAbortErrorfrom the abort signal itself, not corrupted-graph crashes.I have a branch staged with this change plus a deterministic regression test under
packages/core/integration-tests/test/incremental-bundling.js(uses asinon.stubon the default bundler to drive the same catch-path, so it doesn't depend on watcher timing in CI). The test fails without the fix (expected the recovered build to succeed) and passes with it. Happy to open the PR once this issue # is assigned.🔦 Context
This bites real-world ReScript stacks because the ReScript compiler emits a burst of
.bs.jsfiles into the watched output directory whenever anything in the project recompiles. Every recompile triggers the race; the race fails 50–80% of the time (per the matrix above); each failure corrupts the in-memoryBundleGraphcache for the next ~100+ builds until the cache key churns past it. Result:parcel servebecomes effectively unusable when ReScript is rebuilding.The same pattern fires for any external code generator that writes to a watched directory in bursts: protoc, sqlc, GraphQL codegen, Tailwind CLI, etc. The synthetic repro below uses plain shell to remove ReScript from the picture.
Related issues / PRs:
build watchhangs under supervision #9991, parcel watch error: Expected content key 2d39cdf7c618ab5b to exist #8874 — overlapping symptom families (missing dev dep request,Expected content key); same async-reentrancy class.PARCEL_WORKERS=0not eliminating the failure.RequestTracker.js:844(i.e.respondToFSEvents) is the trigger; the partial-graph reuse here is the downstream amplifier.💻 Code Sample
Self-contained, no plugins, no ReScript. Paste this single block into a terminal — it scaffolds a sandbox in
$TMPDIR, runsparcel servevianpx(no install in your working tree), fires the storm for 15 s, prints the failure counts, and cleans up after itself. ~30 s end-to-end on a recent Mac.Expected on a recent Mac with the default 30 s storm: tens of
Build failedand dozens-to-hundreds ofGot unexpected nulllines (45 s scales linearly to the matrix figures above; we observed 40 / 23 in a 15 s run and 94 / 176 in a 45 s run). The race is timing-sensitive — if the first attempt shows zero crashes, re-run withDUR=60 bash <<'REPRO' … REPRO. The bug fires within ~1-2 s of the storm starting once parcel is actively bundling.🌍 Your Environment
Reproduced with
parcel serve(watch mode). Watch backend: FSEvents (default); reproduces under--watch-backend watchmanas well.LLM Disclaimer Issue body drafted with AI assistance (Claude). I know there are many slop PRs out there. This is a real bug I've encountered and this fix seems to address it. While I have my reservations about LLM technology and its uses, this was something that would have taken me long enough to get moving on my local machine that I would have simply continued to suffer the problem (or submitted a much vaguer bug report).