vercel / vercel/next.js

Dev server 500s on every request: concurrent getStaticPaths corrupts prerender-manifest.json

Open
#96,259 4 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dynamic Routes Internationalization (i18n)
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.ts memoizes into _cachedPreviewManifest and the dev server never overrides it, so it normally reads from disk once per process — but the assignment happens after loadManifest() 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, previewProps is in the constructor and the isSSG one goes through the memo. The path that keeps re-reading the file in dev is route-modules/route-module.ts, via shouldCache: !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

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.