-
Notifications
You must be signed in to change notification settings - Fork 30.2k
Description
Link to the code that reproduces this issue
https://github.com/cshawaus/notfound-isr-status-code
To Reproduce
- Build the application for production (
next build) - Start the application (
next start) - Wait until your clock seconds are greater than 30
- Open the application in your browser
- Wait 30 seconds and refresh the page
- Wait another 60 seconds, and refresh the page again
- Wait another 30 seconds, and refresh the page again
Current vs. Expected behavior
Current
Currently, ISR produces an incorrect 404 status code when restoring from the cache if the previous response was a 404. This error occurs when the page content is resolved normally without the existence of __next_error__ in the HTML anymore, due to a previous error.
The 404 status code remains until either revalidatePath or revalidateTag is called.
Expected
The expected behaviour is that the previous cache should be ignored during revalidation, and a fresh 200 status code is generated instead of reusing the old one.
Provide environment information
Operating System:
Platform: darwin
Arch: arm64
Version: Darwin Kernel Version 24.4.0: Wed Mar 19 21:17:25 PDT 2025; root:xnu-11417.101.15~1/RELEASE_ARM64_T6020
Available memory (MB): 32768
Available CPU cores: 12
Binaries:
Node: 22.13.1
npm: 10.9.2
Yarn: 1.22.19
pnpm: 9.15.9
Relevant Packages:
next: 15.3.1 // Latest available version is detected (15.3.1).
eslint-config-next: 15.3.1
react: 19.1.0
react-dom: 19.1.0
typescript: 5.8.3
Next.js Config:
output: N/AWhich area(s) are affected? (Select all that apply)
Not Found, Runtime
Which stage(s) are affected? (Select all that apply)
next start (local)
Additional context
I haven't been able to track down the root cause yet, but I was able to patch packages/next/src/server/base-server.ts locally.
next.js/packages/next/src/server/base-server.ts
Lines 3566 to 3571 in a556825
| // If the request is a data request, then we shouldn't set the status code | |
| // from the response because it should always be 200. This should be gated | |
| // behind the experimental PPR flag. | |
| if (cachedData.status && (!isRSCRequest || !isRoutePPREnabled)) { | |
| res.statusCode = cachedData.status | |
| } |
There is an assumption that a 200 status code should be present, but because the previous response cache is used to fill in some fields, 404 is also possible. See my patch below.
res.statusCode = !cachedData.html.isDynamic && cachedData.html.toUnchunkedString().includes('__next_error__')
? 404
: cachedData.status !== 404 ? cachedData.status : 200;I don't believe this patch causes any side effects. However, because the assumption is that only a 200 status code should be present, there is no way to predict what other possible status codes might be present. Theoretically, only .200 and 404 should be possible, but given app-render runtime seems to respond incorrectly, a more robust check must exist here to validate the outcome before the handler responds
EDIT
Upon further digging, I suspect the following app-render status code checks are related, given the lack of additional places where res.statusCode is set.
| res.statusCode = getAccessFallbackHTTPStatus(err) |
| res.statusCode = getAccessFallbackHTTPStatus(err) |
I suspect app-render doesn't have the proper context to determine when the status code needs to remain a 200, and simply sets it to 404 because of the previous error.
EDIT 2 (07/05/25)
While testing an error state, I noticed the response passed back through the same cachedData handler, reaffirming my suspicion that something within the request handler incorrectly defined the 404 status code. I have updated the patch to include another check for when the status code is not 404: if it is not, use cachedData.status normally; otherwise, use 200.