Dev server 500s on every request: concurrent getStaticPaths corrupts prerender-manifest.json
Nobody has claimed this yet.
- Dominant language
- JavaScript
- Stars
- 142k
- Forks
- 32.4k
- Avg merge
- 2d 14h
- Merged PRs (30d)
- 351
Description
Link to the code that reproduces this issue
https://github.com/LouisTsang-jk/nextjs-prerender-manifest-race
To Reproduce
1. git clone https://github.com/LouisTsang-jk/nextjs-prerender-manifest-race
2. npm install
3. ./repro.sh
repro.sh starts next dev, requests 10 pages under app/[locale]/ concurrently, then checks .next/dev/prerender-manifest.json and issues one more request.
The app is minimal: a root app/[locale]/layout.tsx with generateStaticParams returning 9 locales, 10 trivial pages under it, and one route handler.
Since the failure is a race, a run can come back clean — rerunning hits it within the first couple of attempts on an M-series Mac. race-unit.js in the same repo reproduces the underlying read-modify-write deterministically (consistently ~180 corrupt manifests out of 300 rounds) with no Next.js involved.
Current vs. Expected behavior
Current: once two pages under the same dynamic segment resolve their static paths at the same time, .next/dev/prerender-manifest.json stops being valid JSON and every subsequent request 500s, including requests to completely unrelated routes:
⨯ SyntaxError: Unexpected end of JSON input
at JSON.parse (<anonymous>) {
page: '/en/page-9'
}
GET /en/page-9 500 in 4.9s
Two distinct corruption shapes show up, depending on how the writes interleave:
SyntaxError: Unexpected end of JSON input— a read observes a file that is still being written.SyntaxError: Unexpected non-whitespace character after JSON at position N— valid JSON followed by leftover bytes.
From a real i18n app (9 locales, ~25 pages under app/[locale]/), the on-disk manifest after a collision was 1264 bytes with valid JSON only up to 1192:
..."previewModeEncryptionKey":"<64-hex value X>"}}ey":"<the same value X>"}}
^^^^^^^^^^^^^^^^^^^^^^^^^^
72 bytes left over from a previous, longer write
The trailing fragment repeats the same previewModeEncryptionKey as the valid portion, so both writes came from a single dev server process within one session — not stale cache, not two processes.
Expected: the manifest stays valid JSON regardless of how many pages resolve their static paths concurrently.
Root cause
packages/next/src/server/dev/next-dev-server.ts (getStaticPaths) performs an unsynchronized read-modify-write on that file:
const rawExistingManifest = await fs.promises.readFile(
pathJoin(this.distDir, PRERENDER_MANIFEST),
'utf8'
)
const existingManifest: PrerenderManifest = JSON.parse(rawExistingManifest)
// ...mutate routes / dynamicRoutes...
const updatedManifest = JSON.stringify(existingManifest)
if (updatedManifest !== rawExistingManifest) {
await fs.promises.writeFile(
pathJoin(this.distDir, PRERENDER_MANIFEST),
updatedManifest
)
}
There is no lock, and the write is not atomic. Both writeFile calls open with 'w' and write from offset 0, so when the shorter payload finishes last it only overwrites a prefix of the longer one and leaves the tail in place.
The blast radius comes from base-server.ts calling getPrerenderManifest() on every request (for previewProps and the isSSG check) — so one bad file takes down the whole dev server and the error surfaces on routes that have nothing to do with the pages that raced. In our case it consistently appeared on /api/auth/session, which sent us looking in entirely the wrong place.
Correction (thanks to @zoujimmy82-boop; verified on
canary): the attribution above is wrong, though the user-visible effect is the same.next-server.tsmemoizes into_cachedPreviewManifestand the dev server never overrides it, so it normally reads from disk once per process — but the assignment happens afterloadManifest()returns, so while the file is corrupt the cache never populates and every request re-reads the bad file and re-throws. That is what makes it "every subsequent request", and why a later, longer write silently heals it. Of the two call sites cited,previewPropsis in the constructor and theisSSGone goes through the memo. The path that keeps re-reading the file in dev isroute-modules/route-module.ts, viashouldCache: !this.isDev.
A generateStaticParams on a root layout (the standard i18n setup) amplifies this: every page under [locale] runs its own read-modify-write cycle on first compile, so collision probability grows with page count. We only started seeing it regularly after crossing roughly 20 pages under app/[locale]/.
This is also why the failure looks intermittent and why rm -rf .next "sometimes helps": a later write whose payload is at least as long as the corrupt file silently repairs it, until the next collision.
Provide environment information
Operating System:
Platform: darwin
Arch: arm64
Version: Darwin Kernel Version 27.0.0: Mon Jun 29 21:25:16 PDT 2026; root:xnu-13432.0.50.501.3~1/RELEASE_ARM64_T8112
Available memory (MB): 24576
Available CPU cores: 8
Binaries:
Node: 22.21.1
npm: 10.9.4
Yarn: N/A
pnpm: 10.15.0
Relevant Packages:
next: 16.3.0-canary.97 // Latest available version is detected (16.3.0-canary.97).
eslint-config-next: N/A
react: 19.2.0
react-dom: 19.2.0
typescript: 6.0.3
Next.js Config:
output: N/A
Which area(s) are affected? (Select all that apply)
Dynamic Routes, Internationalization (i18n)
Which stage(s) are affected? (Select all that apply)
next dev (local)
Additional context
Also reproduces on 16.2.7. Not bundler specific — Turbopack and --webpack both hit it, since the code path is in the dev server rather than the bundler.
Workaround that does not require patching node_modules (included in the repro repo as workaround-shim.cjs) — makes writes to that one file atomic:
"dev": "NODE_OPTIONS='--require ./workaround-shim.cjs' next dev"
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start by running repro.sh and race-unit.js in the linked reproduction repository, then inspect getStaticPaths in packages/next/src/server/dev/next-dev-server.ts and the manifest-loading path through route-modules/route-module.ts. Trace the concurrent manifest reads and writes, including the _cachedPreviewManifest behavior. Done means concurrent static-path resolution leaves .next/dev/prerender-manifest.json valid and later requests do not fail with JSON parsing errors.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- next.js, node.js, typescript
- Domain
- build-system, devtools
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 48/100