The problem
Next.js is a powerful and complex React framework that performs many tasks behind the scenes to make applications more responsive and provide an excellent developer experience. However, this sometimes comes with drawbacks.
Let's consider a simple example of the dynamic route built for the App Router:
Loading code example...
Here I create a
Request
instance, and then fetch it. The "mode: no-cors" option basically means that I don't care about the response. This code also includes an unpleasant surprise: it throws an error during the build even before the actual network request is made:TypeError: If request is made from ReadableStream, mode should be "same-origin" or "cors"
This might look strange, because we clearly see, that the original body is not a stream, but a string. And if I try to run the code of
fetchWithRequest
function in a browser console or as a Node.js script – I won't get this error. So why does the Next.js throws it?Looking for the roots
Let's head out to the source code of undici to find out the trigger of this error. There is only a single place, where when some conditions [1][2] are met, the error is thrown.
The trick is that these conditions take place in the
Request
's constructor. So in theory, I should be able to reproduce the error by removing the fetch call:Loading code example...
To my surprise – the error is not thrown 🙃 It means something happens inside the fetch function, and there is a tangible probability that it somehow modifies my
Request
instance, specifically the body property.I've also made some more tests at this point, and I found out that the error reproduces only for the App Router and only for the dynamic routes, which adds even more confusion.
The patched fetch
To prove my assumption, – that Next.js somehow modifies the fetch function – I ran a search query right on the Github, and I found the exact file I looked for – patch-fetch.ts. And, as expected, it modifies the original
Request
:Loading code example...
On the first glance everything looks not that bad. It creates a new
reqOptions
object, fills it with the allowed (requestInputFields
) properties from the original request, and finally passes it as an argument for a new Request
class.The question is – why does Next.js do that? I will answer here very briefly, but will return to the question later. The main thing to pay attention at is an
_ogBody
property – it keeps the "original" body of the client's request. I quoted the "original", because there is actually no way to extract the original body from the constructed Request
object. Consider a simple example:Loading code example...
The answer is… it's a
ReadableStream
. Yeah, we've just definitely lost the original body right after creating a request instance. The request object will always return its body as the ReadableStream or null
. The source body (a string in case of the example) is stored as a private property and available only within the Request
class. It's used in just a single scenario: when the instance is getting cloned (this is the only proper way to reuse the original body).At this step we are very close to understanding the root of the issue. Let's summarize:
- The patched fetch takes the original request and constructs a new one
- It decides which body will be used for the new
Request
instance:._ogBody
or the.body
- The rest properties are left untouched
So in fact this happens:
Loading code example...
Have you already noticed the problem?
👉
req.body
for the new Request is a ReadableStream
, and the cause of the error is now very clear:TypeError: If request is made from ReadableStream, mode should be "same-origin" or "cors"
(╯ ° □ °) ╯ (┻━┻)
The incremental cache
Now let's get back to the
_ogBody
– there are some interesting things about it. The unanswered questions were: why the Next.js adds it and when it uses this property?Next.js uses two different scripts when it comes to the build (dev or prod, doesn't matter):
And it patches the fetch only for the App Router. This brings inconsistency, and causing the reason why I observe the error only for the App Router.
On top of that, for the static routes Next.js utilizes an incremental cache – a mechanism for eliminating requests duplication. That means when the same request pops up during the render cycle, Next.js can reuse the already stored one from its cache. But in order to make the request discoverable in the cache, Next.js needs to somehow serialize it first – basically convert all its properties, including the body, into a string.
But we already know, that the body of the request instance is always a stream (remember the example above?), and the issues is, that a
ReadableStream
body is not serializable by the nature of its structure. So what Next.js does? It converts the stream to a serializable object – an array buffer, and attaches it as a _ogBody
property to the original request. That's, to be honest, kind of substitution of concepts, because _ogBody
doesn't refer to the "original body", it in fact refers to a "serializable body".Later, when the patched fetch is called and the original request has the attached
_ogBody
to it, it's used as a new body (which is weird, because it's not a source body):Loading code example...
And you know what? In this case the error is not thrown! Because the
_ogBody
is already an ArrayBuffer
, not a ReadableStream
anymore, and the ArrayBuffer
doesn't add restrictions to the "mode: no-cors".Just realise this:
- Next.js wants to utilize the incremental cache
- For this it first transforms the request body to an ArrayBuffer to make it serializable
- Then it stores the TRANSFORMED body as the "original" (
_ogBody
)
- Then it reuses this converted body as a source to avoid double conversion, thus unintentionally f̶i̶x̶i̶n̶g̶ suppressing my error
And the last weird part – the incremental cache works only for the static routes by default (they use the array buffer body), what explains why I observe the error only for the dynamic routes (they use the original stream body).
Doubtful design and possible solutions
The combination of such unpredictable behaviour on different stages of the build step, caused me hard times going through the debug of Next.js and Node.js (not to mention
ky
and NotionX
, who started it all). And what was the reason – a simple fetch call?I strongly believe that patching client's code is a tough decision, which requires strong argumentation, a very careful implementation and thorough maintenance. I think from the side of the Next.js it can be explained as "it just works out of the box", but in practice it inflicts restrictions and inconsistency:
- Next.js forces using
fetch
for any request logic. Without it, the cache won't work. But in JS we actually have other options, likexhr
andhttp
module, which are implicitly forbidden.
- The
Request
instance is copied only over therequestInputFields
properties, which may and will filter out any custom properties attached to the source request object. They basically prevent us doing what they do when attaching a custom_ogBody
property, LOL.
- Patched
fetch
directly breaks the client's input parameters by overriding and transforming theRequest
body
- Next.js also allows configuring the environment, which in my case also affected the cache behavior when set to the "edge".
If we look at the past, the same decision one day was introduced by the React team with their React.cache function for RSC. But later it was disabled due to the amount of the potential issues and doubts from the community. I didn't find much of the issues raised on the internet regarding of the cache mechanism of the Next.js, just this one. And I understand why – it's quite a complex topic, and something which is treated "if Next.js decided to do so, then they know what they do".
In my opinion, If they decide to patch (or like they say "to extend") the fetch, it was extremely important for them to constantly run this modified fetch version over the existing spec-compliant tests to ensure it's fully compatible and doesn't throw errors on basic usage. On top of that, they needed to run it through the tests of at least all the popular request libraries, like
axios
, ky
, got
and others. This seems like an extra step, because they shouldn't test the work of the other libraries, but in this case this would help to test their own patched fetch for compatibility issues, not the libraries themself they run tests against.The best solution, of course, would be a standalone http adapter, which handles all that cache logic separately, bringing a control back to the user space. The same way they already do this in react components:
next/dynamic
, next/image
, next/link
. Or something like the exported constant from the module that enables the patch, which would help in debugging simply by disabling it:Loading code example...
For the moment of writing this article there are more than 2.3k opened issues (including mine) on the Next.js repo, which is frighteningly a lot. Some of them may become a basement for new features, and the others have real chances to be left unnoticed for ages, while the team is focused on implementing new features, what makes the snowball of the raised issues even bigger.
I really believe Next.js could handle all these issues and one day will release a more predictable and reliable solution for the resource caching.