Open peibolsang opened 9 months ago
I kept experimenting, and I came to the following conclusions.
cache
is different from Next.js unstable_cache
and both are different from serving content cached from a CDN.unstable_cache
is all about server-side cache. It means the data is cached on the Next.js server (unsure if it is in memory or on a light database). Therefore, you can still perceive a little rendering delay even for content served from the cache. This is because the data does not come from a CDN. It comes from fetch()
calls that retrieve data from the server cache instead of from the origin.fetch()
calls happen at build time, and the browser needs to download the generated HTML+CSS+Javascript.x-vercel-cache
is always a MISS
instead of a HIT
for static pages with the edge
runtime. This is a significant issue if you want to use CDN cache, and excellent comments on the issue confirm this. If you use server cache, this does not affect you.edge
, I experienced the same problem using plain unstable_cache
with nodejs
runtimes. My problem was that I got cache NONE
instead of MISS
in Vercel logs, but a big MISS
in x-vercel-cache
. Maybe my problem was that I couldn't make cache tags work, so I followed the solutions recommended in the issue.export const dynamic = "force-static"
at the page level. This will generate a static output served from the CDN, and it is way faster than serving content from the Next.js server's cacheexport const revalidate = 3600
revalidatePath()
and revalidateTag()
is that while the former revalidates a concrete path, the latter revalidates an entire cache tag, which makes it practical to serve fresh data across different paths in your app navigation.Update about my problems with unstable_cache
above!
The header being a MISS means the entire page is not cached. Thereโs some dynamic code. Itโs possible you have both a cached value using unstable_cache() and other dynamic code like cookies().
However, I don't have any type of dynamicity in my layout or page (nothing like force-dynamic
, no-cache
, noStore()
, cookies()
, or headers()
). All I have is an innocent fetch()
call wrapped by unstable_cache()
.
But, because I am an idiot, my page is a dynamic route (e.g., app/[user]
); therefore, it can't be cached in the CDN. If I want this dynamic route to be cached entirely, I need to put an export const dynamic="force static"
, which is what I ended up doing anyway.
All this means the cache MISS
in x-vercel-cache
header I have been experiencing is a CDN Cache MISS
for the page, not a Next.js Server cache MISS
for the data. I know that unstable_cache
actually works and serves cached data, although the entire page is not served from the CDN because it's a dynamic route.
This also means I can use edge
runtime for dynamic routes if I want! I can't use edge
for the landing page because it's not a dynamic route and because of the issue above, we'll get a cache MISS
always.
All the findings above prompt the following question: why don't we mark all our pages (landing and dynamic routes) as static pages to be served from the CDN with export const dynamic="force static"
to get the optimum performance?
Then, we can revalidate tags/pages when data mutations happen.
revalidatePath()
or revalidateTag()
, so users will get fresh data after a mutation.I tested this, and it works. For use case number 2 (i.e., data mutations happen in your system), the data revalidation itself is fast, but:
x-vercel-cache=REVALIDATED
the next time it is accessed. The first access will be slow, but subsequent access will get a HIT
again and will be faster.x-vercel-cache=REVALIDATED
the next time it is accessed. The first access will be slow, but subsequent access will get a HIT
again and will be faster.What am I missing?
Note: The only disadvantage of
force-static
is that it does not work with theedge
because this issue has not been solved: https://github.com/vercel/next.js/issues/42112
Why am I doing this?
Early this year, I migrated Octotype (a blogging platform I created using GitHub Issues as a headless CMS) to Next.js 14. The migration experience itself was great, and working with the new App Router and React Server Components was bliss. The mental model these two new paradigms bring to the table is absolutely a step in the good direction for a better DX (and, therefore, for a better UX).
Because Octotype does not have a database and relies fully on a 3rd-party API (GitHub API), I got obsessed with fine-tuning the site's performance to the maximum. Little did I know that I got drunk with options, and I struggled to understand the differences between all of them:
After a few days, I finally figured it all out (or that's what I think ๐), so here I am sharing what I have learned. This is a summary of all the rendering options brought to the table by Next.js 14, and during this post, I'll direct the tradeoffs of each one of them with real code examples and videos.
Let's get into it!
1. SSG
One of the first lessons I learned is that Next.js pre-renders your site entirely at build time by default. This means that even if you use React Server Components, the server is the CI server. It will both fetch data and render HTML on the server at build time, so the browser will just need to download static files (HTML, CSS, and Javascript).
This is what we call Static Site Generation because now your entire site is static.
Here are some examples. This is how my user page looks like with SSG. Nothing is magical here; it's just a normal Next.js page using a
UserPosts
RSC.app/[user]/page.tsx
This is what my
UserPosts
RSC looks like with SSG. All it does is call agetUserPosts()
function to fetch posts (yes, usingNext.js augmentedfetch()
) and pass the data down to aUserPostsGrid
client component for final rendering.components/server/user-posts.tsx
As you can see in the video, the navigation is pretty fast.
SSG Video
And this is why it's fast. At run time (
time
arrow), all the browser does is download static content because the actual data fetching and rendering has been done at build time. Put in a graph, SSG can be represented like this:PROS โ Amazing performance with a ver low loading time.
CONS โ I don't have dynamic data! If the user writes a new blog post, I need to build the site (manually or force a build) so it can be included in the static output
It would be nice if I could configure my app to automatically build the site every
x
seconds to guarantee fresh data at a given time.Well, enter Incremental Site Regeneration.
2. ISR
Incremental Site Regeneration allows you to configure your site to generate a static output automatically at a given configurable frequency.
And this is where I started to get drunk with options. The thing is, Next.js is super-flexible and allows you to define what is that static output. In other words, you can configure your revalidation frequency at the page, component, or the
fetch
request levels.Because I am a big fan of React's component-driven model, I started putting it at the component level. I leveraged the new Next.js 14
cache()
function available in thenext/cache
package. It's unstable, but my site is simple, and I have yet to find any problems. This implementation basically follows astale-while-revalidate
pattern, where you specify your data fetching function, tag your cache, and establish your revalidation frequency. I love this pattern because it serves you stale data for a period until it's time to serve fresh new data, which has been fetched in the background by the Next.js server.This is what my
UserPosts
RSC looks like with an ISR of 1 hour. No changes are needed to my userpage.tsx
.components/server/user-posts.tsx
PROS โ Amazing performance for cache hits
MEHS ๐ถ Eventual fresh data
CONS โ Mediocre performance for cache misses โ Data is not fresh on demand
ISR is great for many use cases, and I actually use it in other parts of Octotype. However, if you (like me) are unwilling to pay the costs of these tradeoffs and always want fresh data at particular parts of the application, you may want to render your content dynamically on demand.
Now, enter Server-side Rendering.
3. Traditional SSR
This is equal to the traditional rendering model (PHP, JSP, ASP, etc.), where your data is fetched at the server, and your page is rendered based on that data. The client then downloads a fully operational page and hydrates it so the user can start working with it.
I won't enter into debates about if Next.js is reinventing these other technologies mentioned above. The main difference for me is the ability to combine server-side rendering with rich client interactivity following a component-based model (not MVC, there are no views here) where your UI is expressed declaratively. That mental model, for me, is a perfect fit.
Now, this is what my
UserPosts
RSC looks like with SSR. No changes are needed to my userpage.tsx
. I only had to stop using thecache()
function and start using the newnoStore()
function. This forces my component to be completely dynamic and always fetch data on demand. Again, Next.js allows you to define this dynamism at the page, component, orfetch
levels, but you already know my choice and way.components/server/user-posts.tsx
With this change, the navigation to this page isn't that fast. I am always getting fresh data at the expense of loading time. But this comes with a caveat; now, because I am using a dynamic component through the
noStore()
function, my whole page route becomes dynamic, as you can see in this video:SSR Video
If we put this in a graph, this is happening.
PROS โ Always fresh data โ Better perceived performance
CONS โ Performance could be not great for huge data loads or slow data connection links โ By using a dynamic component, my whole page route becomes dynamic
I think people working at Next.js realized these tradeoffs and asked themselves: "Why do data fetching and rendering have to be sequential?" If you look at the
UserPosts
RSC code above, the server first fetches the posts and only then starts rendering the content. It means rendering is blocked by data fetches, but not all that content depends on data! If only there were a way to parallelize some of the things that happen at the server!Enter Streaming.
4. Streaming
Streaming is like chopping up your data into chunks and sending them over progressively from the server to your screen. This way, you're not stuck waiting for everything to load before you can use the page. You get to see and mess around with parts of the site without hanging around for every piece of data to appear.
I like this approach because, in my mind, a chunk equals a component. This is the mental model I use, so this thing immediately clicked for me.
In my code, all I had to do to enable streaming when I deployed my site on Vercel was add
Suspense
boundaries around my RSC. Practically, this means adding a<Suspense>
tag with a fallback, which is a component that will be rendered while my RSC gets processed (i.e., data is fetched and HTML is rendered) in parallel.This is all it takes to enable streaming. There are no changes to my RSC, just a tiny change to my
page.tsx
:app/[user]/page.tsx
As you can see in this video, there is an improved real and perceived performance when loading the user posts. The page is shown faster, and a skeleton is rendered while the data is loaded and streamed in parallel. This is because the JSX inside the
return
statement is processed parallel to theUserPosts
RSC processing (data fetching and rendering).Streaming Video
This graph explains what's going on behind the scenes. As you can see, there is a substantial gain in performance, and the user can start interacting with the page sooner, compared to SSR:
The skeleton is shown during a brief time interval. This is because the amount of data fetched on the server is not huge, and the connection link to the GitHub API is fast. It means that the time difference between the shell page being ready on the client (rendered on the server and then downloaded in the browser) and the RSC being ready on the client (rendered on the server and downloaded in the browser) is very small.
PROS โ Always fresh data โ Even better perceived performance
MEHS ๐ถ There is still a time gap between the user clicking a link and the shell page being displayed on the browser, although content chunks keep streaming
CONS โ By using a dynamic component, my whole page route becomes dynamic
Again, the folks at Next.js put their thinking hats on and asked themselves: "Why does the whole page route need to be dynamic?," "Can't we combine the best parts of SSG for the static parts and the best parts of streaming for the dynamic ones?".
Yes, we can. Enter Partial Pre-rendering
5. Partial Pre-Rendering
I'll quote the Vercel folks on this one:
In other words, the static parts of your page route are pre-rendered. This can be done at build time (SSG) or even based on revalidation frequency (ISR). Then, the dynamic parts of the same page route are streamed once the initial page shell has been loaded.
This is the only change I needed to make it work in my app on top of streaming. Nothing else, and you can see, it's still experimental.
next.config.js
Because the page shell has been pre-rendered, the first-page load is lightning fast as it is a pure static output served from a CDN. See this behavior in action in the following video:
PPR Video
Compare this graph with the others above! This is really amazing:
With PPR, the skeleton is shown during a longer interval. This is because the skeleton has been pre-rendered with the page shell and included in the static output, which means it's available at the client way sooner. This has an incredibly positive impact on perceived performance.
PROS โ Amazing performance, real and perceived, with always fresh data.
CONS โ It's experimental. I am not using it in Octotype at this point.
Bonus: SPA
All the options described above involve some server-side rendering in one form or another with Next.js. These are all default features of the framework, which does not mean you can't do a SPA with Next.js. You can, but it isn't obvious, as it requires some additional work outside the responsibilities of the framework.
Let me explain.
First, you must translate your RSC into a client component and fetch data using React classic hooks,
useState
anduseEffect
. Nothing special here.components/client/user-posts.tsx
Second, you can leave your default routing untouched using the App Router, but you need to export your application as an MPA so it can be hosted in a non-node environment, such as Amazon S3. This means you must build your application using the command
next build && next export
. (Remember, by default, Next.js SSG generates a node app).Third and final (and this is not trivial) because you now have an HTML file per route instead of a single
index.html
, you need to write some custom logic to map your site routes to these different files. This can be done via a script innginx
or an Edge Lambda in AWS CloudFront, but you must do it yourself.But hey, bear with me. Dan explains this better than I do and even includes some code examples on how to do it!
This is what SPA processing looks like in a graph. As you can see, the client download time is still okay. Unlike other SPAs, Next.js enables a better SPA as the client does not have to download an entire unique bundle containing the entire application. However, fetching data from the client is slower than on the server (.... at least theoretically!). In other words, it's worth analyzing the performance differences between Next.js SPA and SSR methods in detail, as differences can be minimal.
Summary
While I love frameworks that give you options, I got drunk with them. Documentation is still getting there, but the Next.js team is doing a great job creating educational material as some of these features are adopted and refined. Consequently, we are in a stage where much of the learning comes through rolling up your sleeves and start building, which is a good method anyway. And if you build in public and share your knowledge, you're contributing to improving all these features.
What rendering option is better? It all depends on your application and how you manage data. For example, it wouldn't make sense always to get fresh data for a blog post because its content does not change frequently. This would make ISR a good option for this use case. On the contrary, switch to SSR or Streaming if you have a page that renders content based on data that changes very often and you always want to show it fresh on demand. Lastly, if you have a mixed page with very clear static parts that don't change often, mixed with dynamic parts that change frequently, that makes it perfect for PPR. And, to be honest, this method could even become the default for many pages on many sites out there. And that's big.
The following picture represents an overall summary of all these rendering methods.