Skip to content

Dev server reads its emitted manifests while they are zero bytes, failing in-flight requests during a rebuild #97594

Description

@Faolain

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:

  1. 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.

  2. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions