Link to the code that reproduces this issue
https://github.com/Faolain/nextjs-dev-manifest-empty-race
To Reproduce
npm install
npm run repro # boots the dev server, then hammers it for 90s
The script keeps requests to 12 App Router routes in flight continuously while
appending to a client component to force rebuilds underneath them. It prints
REPRODUCED and exits 0 as soon as the error lands in dev.log, typically
within the first 30s.
Both ingredients are required: concurrent requests and a rebuild while they
are in flight. Requesting one route at a time will not reproduce it.
Current vs. Expected behavior
Current: a request that overlaps a rebuild can read one of the bundler's
emitted manifests while the file is zero bytes, and the request fails:
⨯ Error: Manifest file is empty
at ignore-listed frames { page: '/pricing' }
GET /pricing 500 in 1036ms
Expected: a rebuild does not make in-flight requests fail.
Analysis
In dev, RouteModule loads its manifests with shouldCache: !this.isDev, so
every request re-reads them from disk rather than reading a cached copy. The
bundler rewrites those same files on every rebuild. A request landing between a
manifest being created and its contents being written reads a zero-length file.
Which error you get depends only on which loader lost the race:
| manifest |
loader |
symptom |
.next/dev/server/app/<page>_client-reference-manifest.js |
evalManifest |
Error: Manifest file is empty (E328) |
.next/dev/server/next-font-manifest.json |
loadManifest |
SyntaxError: Unexpected end of JSON input |
.next/dev/build-manifest.json |
loadManifest |
SyntaxError: Unexpected end of JSON input |
Two measurements that I think matter for choosing a fix:
-
The file is always exactly zero bytes, never partially-written garbage. I
instrumented loadManifest to log path and content.length on parse
failure; every hit was len=0.
-
It stays zero bytes for a sustained window, so this is not a torn write. I
instrumented both loaders to re-read up to 5 times over ~10ms before giving
up. Every retry exhausted all 5 attempts with the file still at len=0. A
short retry inside the read does not fix this.
handleMissing does not help today: it catches the readFileSync throw for a
file that is absent, but a file that exists and is empty does not throw, so it
falls through to JSON.parse('') or to the E328 throw.
Possible fixes
The root fix is presumably that these manifests should reach their final path
atomically (write + rename), so a reader can never observe the empty window.
That matches the direction of #96384 / #96695, which use write-file-atomic for
prerender-manifest.json — though those are Next's own writes, whereas the three
manifests above are emitted as bundler assets, so the write path is different.
As a narrower mitigation I opened #97593, which makes evalManifest treat a
zero-length read the same way it already treats an absent file when the caller
passed handleMissing (that call site already optional-chains the result). In
the repro that removes the E328 failures completely. It deliberately does not
address the two JSON manifests, because those call sites do not pass
handleMissing and their consumers are not typed for undefined — so I did not
want to change their contract without a maintainer's read on it.
Notes
Verified on next@16.3.0 (webpack), macOS 15, Node 22.15. 16.3.1 and current
canary contain the identical loader — I diffed the published
dist/server/load-manifest.external.js and the canary source, and neither has
any handling for an empty read.
This was originally filed as #96971, which the bot auto-closed for an invalid
reproduction link. This issue carries a working public repro.
Link to the code that reproduces this issue
https://github.com/Faolain/nextjs-dev-manifest-empty-race
To Reproduce
npm install npm run repro # boots the dev server, then hammers it for 90sThe script keeps requests to 12 App Router routes in flight continuously while
appending to a client component to force rebuilds underneath them. It prints
REPRODUCEDand exits0as soon as the error lands indev.log, typicallywithin the first 30s.
Both ingredients are required: concurrent requests and a rebuild while they
are in flight. Requesting one route at a time will not reproduce it.
Current vs. Expected behavior
Current: a request that overlaps a rebuild can read one of the bundler's
emitted manifests while the file is zero bytes, and the request fails:
Expected: a rebuild does not make in-flight requests fail.
Analysis
In dev,
RouteModuleloads its manifests withshouldCache: !this.isDev, soevery request re-reads them from disk rather than reading a cached copy. The
bundler rewrites those same files on every rebuild. A request landing between a
manifest being created and its contents being written reads a zero-length file.
Which error you get depends only on which loader lost the race:
.next/dev/server/app/<page>_client-reference-manifest.jsevalManifestError: Manifest file is empty(E328).next/dev/server/next-font-manifest.jsonloadManifestSyntaxError: Unexpected end of JSON input.next/dev/build-manifest.jsonloadManifestSyntaxError: Unexpected end of JSON inputTwo measurements that I think matter for choosing a fix:
The file is always exactly zero bytes, never partially-written garbage. I
instrumented
loadManifestto logpathandcontent.lengthon parsefailure; every hit was
len=0.It stays zero bytes for a sustained window, so this is not a torn write. I
instrumented both loaders to re-read up to 5 times over ~10ms before giving
up. Every retry exhausted all 5 attempts with the file still at
len=0. Ashort retry inside the read does not fix this.
handleMissingdoes not help today: it catches thereadFileSyncthrow for afile that is absent, but a file that exists and is empty does not throw, so it
falls through to
JSON.parse('')or to the E328throw.Possible fixes
The root fix is presumably that these manifests should reach their final path
atomically (write + rename), so a reader can never observe the empty window.
That matches the direction of #96384 / #96695, which use
write-file-atomicforprerender-manifest.json— though those are Next's own writes, whereas the threemanifests above are emitted as bundler assets, so the write path is different.
As a narrower mitigation I opened #97593, which makes
evalManifesttreat azero-length read the same way it already treats an absent file when the caller
passed
handleMissing(that call site already optional-chains the result). Inthe repro that removes the E328 failures completely. It deliberately does not
address the two JSON manifests, because those call sites do not pass
handleMissingand their consumers are not typed forundefined— so I did notwant to change their contract without a maintainer's read on it.
Notes
Verified on
next@16.3.0(webpack), macOS 15, Node 22.15.16.3.1and currentcanarycontain the identical loader — I diffed the publisheddist/server/load-manifest.external.jsand the canary source, and neither hasany handling for an empty read.
This was originally filed as #96971, which the bot auto-closed for an invalid
reproduction link. This issue carries a working public repro.