02nd June 2024

Mastering Caching Strategies in Next.js for Optimal Performance

Caching-VS-Online-img

Next.js is a fantastic framework that simplifies the creation of complex server-rendered React applications. However, there's a significant challenge: its caching mechanism is highly intricate, often leading to bugs that are tough to diagnose and resolve.

Without a clear understanding of how caching works in Next.js, it can feel like a constant struggle, preventing you from fully benefiting from its powerful performance enhancements. This article aims to demystify Next.js's caching system, detailing every aspect to help you harness its potential without the frustration.

To start, here’s an image showing how the different caches in Next.js interact. While it may seem overwhelming initially, by the end of this article, you will have a comprehensive understanding of each step in the process and how they work together.

cache-interactions

In the image above, you likely noticed the terms "Build Time" and "Request Time." To prevent any confusion as we proceed, let's clarify these concepts.

Build time occurs when an application is constructed and deployed. Any content cached during this phase, primarily static content, becomes part of the build time cache. This cache is refreshed only when the application is rebuilt and redeployed.

Request time , on the other hand, refers to the moment when a user requests a page. The data cached during request time is typically dynamic, as it is fetched directly from the data source in response to user requests.

Next.js Caching Mechanisms

Grasping the intricacies of Next.js caching can initially seem overwhelming due to its four distinct caching mechanisms, each functioning at different stages of your application and interacting in seemingly complex ways.

Here are the four caching mechanisms in Next.js:

  • Request Memoization
  • Data Cache
  • Full Route Cache
  • Router Cache

In this article, I'll dive into each of these mechanisms, explaining their specific roles, where they are stored, their duration, and how to effectively manage them, including methods for cache invalidation and opting out. By the end, you’ll have a thorough understanding of how these mechanisms collaborate to enhance Next.js’s performance.

Request Memoization

A common issue in React arises when the same information needs to be displayed in multiple places on a single page. The straightforward approach is to fetch the data wherever it’s needed, but this isn’t ideal because it results in multiple server requests for the same data. This is where Request Memoization becomes valuable.

Request Memoization is a feature in React that caches every fetch request made in a server component during the render cycle, which refers to the process of rendering all the components on a page. If a fetch request is made in one component and the same request is made in another component, the second request won’t reach the server. Instead, it will retrieve the cached value from the first request.

                                
                                    
  export default async function getUserData(userId) {
    // Next.js automatically caches the 'fetch' function
    const response = await fetch('https://api.example.com/users/userId');
    return response.json();
  }
   
  export default async function Page({ params }) {
    const userData = await getUserData(params.id);
   
    return (
  <>
  <h1>{userData.name}</h1>
  <UserDetails userId={params.id} />
  </>
    );
  }
   
  async function UserDetails({ userId }) {
    const userData = await getUserData(userId);
    return <p>{userData.name}</p>;
  }
  
                                
                            

In the code above, we have two components: Page and UserDetails. The initial call to the getUserData function in Page makes a standard fetch request, and the response from this request is stored in the Request Memoization cache. When getUserData is called again in the UserDetails component, it doesn't make a new fetch request. Instead, it uses the memoized value from the first request. This optimization significantly improves the performance of your application by reducing the number of server requests, and it also simplifies the component code since you don't need to manually optimize your fetch requests.

It's important to understand that this cache is entirely server-side, meaning it only caches fetch requests made from your server components. Additionally, the cache is cleared at the start of each request, so it is only valid for a single render cycle. This is intentional, as the cache's purpose is to eliminate duplicate fetch requests within a single render cycle.

Finally, note that this cache only stores fetch requests made with the GET method. To be memoized, a fetch request must also have the exact same parameters (URL and options).

Caching Non-fetch Requests

React, by default, only caches fetch requests. However, there are scenarios where you might need to cache other types of requests, such as database queries. To achieve this, you can use React’s cache function. Simply pass the function you want to cache to cache, and it will return a memoized version of that function.

                                
                                    
  import { cache } from "react";
  import { queryDatabase } from "./databaseClient";
  
  export const getUserData = cache(async (userId) => {
    // Perform a direct database query
    return queryDatabase("SELECT * FROM users WHERE id = ?", [userId]);
  });

                                
                            

In the code above, the first time getUserData() is called, it directly queries the database since there is no cached result yet. However, the next time this function is called with the same userId, the data is retrieved from the cache. Similar to fetch memoization, this caching is only valid for the duration of a single render pass and operates identically to fetch memoization.

Revalidation

Revalidation involves clearing the cache and updating it with fresh data. This is crucial because a cache that is never updated will eventually become stale and outdated. Fortunately, with Request Memoization, we don't have to worry about revalidation since the cache is only valid for the duration of a single request.

Opting Out

To opt out of this cache, you can pass an AbortController signal as a parameter to the fetch request.

                                
                                    
async function getUserData(userId) {
  const { signal } = new AbortController();
  const response = await fetch('https://api.example.com/users/userId', {
    signal,
  });
  return response.json();
}

                                
                            

Avoiding caching with an AbortController signal instructs React not to store the fetch request in the Request Memoization cache. However, it's advisable to use this approach judiciously, as the cache can significantly enhance your application's performance.

The diagram below offers a visual overview of Request Memoization's functionality.

request-memoization
Data Cache

While Request Memoization excels at preventing duplicate fetch requests within a single render cycle, it falls short when it comes to caching data across requests or users. This is where the Data Cache shines. It serves as the final cache that Next.js consults before actually fetching data from an API or database, and it persists across multiple requests and users.

Consider a scenario where we have a basic page that retrieves guide data for a specific city from an API.

                                
                                    
  import { cache } from "react";
  
  export default async function Page({ params }) {
    const city = params.city;
    const guideData = await getGuideData(city);
  
    return (
  <div>
  <h1>{guideData.title}</h1>
  <p>{guideData.content}</p>
        {/* Render the guide data */}
  </div>
    );
  }
  
  export const getGuideData = cache(async (city) => {
    const response = await fetch('https://api.globetrotter.com/guides/city');
    return response.json();
  });

                                
                            
Duration and Revalidation in the Data Cache

The guide data, in this case, is unlikely to change frequently. Fetching it fresh every time someone needs it is inefficient. Instead, caching this data across all requests would ensure it loads instantly for future users. Fortunately, Next.js handles this automatically for us with the Data Cache.

By default, every fetch request in your server components is cached in the Data Cache, which is stored on the server. This cached data is then used for all future requests. For example, if you have 100 users all requesting the same data, Next.js will only make one fetch request to your API and then use that cached data for all 100 users. This significantly boosts performance.

  • Duration: Data in the Data Cache differs from the Request Memoization cache in that it is never cleared unless explicitly instructed by Next.js. This data persists even across deployments, meaning that if you deploy a new version of your application, the Data Cache remains intact.
  • Revalidation: Since the Data Cache is never cleared by Next.js, we need a way to opt into revalidation, which is the process of removing data from the cache. Next.js offers two ways to achieve this: time-based revalidation and on-demand revalidation.
  • Time-based Revalidation: The simplest way to revalidate the Data Cache is to automatically clear the cache after a set period. This can be done in two ways.
                                
                                    
  const res = fetch('https://api.globetrotter.com/guides/city', {
    next: { revalidate: 3600 },
  });

                                
                            

The first method involves using the next.revalidate option in your fetch request. This option specifies how many seconds Next.js should keep your data in the cache before considering it stale. In the example above, we are instructing Next.js to revalidate the cache every hour.

Another approach is to set a revalidation time using the revalidate segment in the configuration options.

                                
                                    
  export const revalidate = 3600;
  
  export default async function Page({ params }) {
    const city = params.city;
    const res = await fetch('https://api.globetrotter.com/guides/city');
    const guideData = await res.json();
  
    return (
  <div>
  <h1>{guideData.title}</h1>
  <p>{guideData.content}</p>
        {/* Render the guide data */}
  </div>
    );
  }

                                
                            
Understanding Time-based Revalidation

When implementing time-based revalidation, all fetch requests for a page will be revalidated every hour, unless they have their own more specific revalidation time set.

It's crucial to understand how time-based revalidation handles stale data. Here's the process:

  • Initial Fetch: The first fetch request retrieves the data and stores it in the cache.
  • Cached Data Usage: Subsequent fetch requests made within the 1-hour revalidation time use the cached data without making additional fetch requests.
  • Revalidation After 1 Hour: After 1 hour, the first fetch request will still return the cached data, but it will also execute a fetch request to get the newly updated data and store it in the cache. From this point on, new fetch requests will use the newly cached data.
  • Stale-while-Revalidate: This pattern, where stale data is used while new data is fetched in the background, is called stale-while-revalidate and is the behaviour that Next.js employs.
On-demand Revalidation

For data that is not updated regularly, on-demand revalidation can be used to revalidate the cache only when new data is available. This approach is suitable for scenarios where you want to invalidate the cache and fetch new data only when a new article is published, or a specific event occurs.

On-demand revalidation can be implemented in one of two ways.

                                
                                    
  import { revalidatePath } from "next/cache";
  
  export async function publishArticle({ city }) {
    createArticle(city);
  
    revalidatePath('/guides/city');
  }

                                
                            

For more specific revalidation, you can use the revalidateTag function, which clears the cache for all fetch requests with a specific tag

                                
                                    
  import { revalidateTag } from "next/cache";
  
  export async function publishArticle({ city }) {
    createArticle(city);
  
    revalidateTag("city-guides");
  }

                                
                            
Opting Out of the Data Cache

To opt out of the Data Cache, you can use the cache: "no-store" option in your fetch request, ensuring the request is not cached:

                                
                                    
  const res = fetch('https://api.globetrotter.com/guides/city', {
    cache: "no-store",
  });
 
                                
                            

Alternatively, you can use the unstable_noStore function to opt out of the Data Cache for a specific scope:

                                
                                    
 import { unstable_noStore as noStore } from "next/cache";
 
  function getGuide() {
    noStore();
    const res = fetch('https://api.globetrotter.com/guides/city');
  }
 
                                
                            

For broader opt-out, you can use the dynamic segment config option at the top level of your file to force the page to be dynamic and opt out of the Data Cache entirely:

                                
                                    
  export const dynamic = "force-dynamic";
  export const revalidate = 0;
 
                                
                            

This sets the entire page to be dynamic, ensuring nothing is cached.

Caching Non-fetch Requests

Up to now, we've focused on caching fetch requests with the Data Cache, but we can do much more with it.

Let's revisit our example of city guides. In some cases, we may want to retrieve data directly from our database. To achieve this, we can utilize the cache function provided by Next.js. This function is similar to the React cache function but applies to the Data Cache instead of Request Memoization.

                                
                                    
 import { getGuides } from "./data";
  import { cache as unstable_cache } from "next/cache";
  
  const getCachedGuides = unstable_cache(city => getGuides(city), ["guides-cache-key"]);
  
  export default async function Page({ params }) {
    const guides = await getCachedGuides(params.city);
    // ...
  }
 
                                
                            

This feature is currently experimental, indicated by the prefix unstable_, but it is the sole method available for caching non-fetch requests in the Data Cache.

The cache function in the provided code snippet is concise but may be perplexing for those encountering it for the first time.

This function takes three parameters, but only two are mandatory. The first parameter is the function to be cached, which in this case is getGuides. The second parameter is the cache key, necessary for Next.js to distinguish between different caches. The key is an array of strings and must be unique for each cache. If two cache functions have the same key array, they are considered identical requests and stored in the same cache (similar to fetch requests with the same URL and params).

The third parameter is optional and allows you to specify options such as revalidation time and tags.

In this specific code, we are caching the results of the getGuides function and storing them in the cache with the key ["guides-cache-key"]. Consequently, if getCachedGuides is called twice with the same city, the second call will use the cached data instead of invoking getGuides again.

data-cache

The third type of cache in Next.js is the Full Route Cache, which is relatively straightforward compared to the Data Cache. This cache is particularly useful because it allows Next.js to cache static pages at build time, eliminating the need to build those pages for each request.

In Next.js, the pages rendered to clients consist of HTML and a component called the React Server Component Payload (RSCP). This payload contains instructions for how the client components should interact with the rendered server components to render the page. The Full Route Cache stores both the HTML and RSCP for static pages at build time.

Now, let's illustrate this with an example.

                                
                                    
 import Link from "next/link"
 
  async function getBlogList() {
    const blogPosts = await fetch("https://api.example.com/posts")
    return await blogPosts.json()
  }
  
  export default async function Page() {
    const blogData = await getBlogList()
  
    return (
  <div>
  <h1>Blog Posts</h1>
  <ul>
          {blogData.map(post => (
  <li key={post.slug}>
  <Link href={'/blog/post.slug'}>
  <a>{post.title}</a>
  </Link>
  <p>{post.excerpt}</p>
  </li>
          ))}
  </ul>
  </div>
    )
  }
 
                                
                            

In the provided code, the Page component will be cached at build time because it does not contain any dynamic data. Specifically, its HTML and React Server Component Payload (RSCP) will be stored in the Full Route Cache, enabling faster serving when a user requests access. The only way this cached HTML/RSCP will be updated is by redeploying our application or manually invalidating the data cache that this page depends on.

Despite the fetch request in the code, the data fetched is cached by Next.js in the Data Cache. Therefore, this page is actually considered static. Dynamic data, on the other hand, refers to data that changes on every request to a page, such as dynamic URL parameters, cookies, headers, search parameters, etc.

Similar to the Data Cache, the Full Route Cache is stored on the server and persists across different requests and users. However, unlike the Data Cache, the Full Route Cache is cleared every time you redeploy your application.

Opting Out of the Full Route Cache

There are two ways to opt out of the Full Route Cache:

  • Opting Out of the Data Cache: If the data you are fetching for the page is not cached in the Data Cache, then the Full Route Cache will not be used.
  • Using Dynamic Data: Incorporating dynamic data in your page, such as headers, cookies, or searchParams dynamic functions, and dynamic URL parameters (e.g., id in /blog/[id]), will also prevent the Full Route Cache from being utilized.

The diagram below provides a step-by-step illustration of how the Full Route Cache functions.

route-cache
Router Cache

The Router Cache is a unique cache that differs from the others in that it is stored on the client side instead of the server side. However, it can be a source of confusion and potential bugs if not properly understood. This cache stores routes that a user visits, so when they revisit those routes, the cached version is used instead of making a request to the server. While this can significantly improve page loading speeds, it can also lead to unexpected behavior, as outlined below.

Please note that this cache is only active in production builds. In development, all pages are rendered dynamically, so they are never stored in this cache.

                                
                                    
  export default async function Page() {
    const blogData = await getBlogList()
  
    return (
  <div>
  <h1>Blog Posts</h1>
  <ul>
          {blogData.map(post => (
  <li key={post.slug}>
  <Link href={'/blog/post.slug'}>
  <a>{post.title}</a>
  </Link>
  <p>{post.excerpt}</p>
  </li>
          ))}
  </ul>
  </div>
    )
  }
 
                                
                            
Router Cache

In the provided code snippet, when a user navigates to the page or any of the /blog/${post.slug} routes, the HTML/RSCP is stored in the Router Cache. Subsequently, if the user revisits a page they have previously viewed, the Router Cache is used to retrieve the cached HTML/RSCP instead of making a request to the server.

  • Duration: The Router Cache duration depends on the type of route. For static routes, the cache is stored for 5 minutes, while for dynamic routes, it is stored for only 30 seconds. If a user revisits a static route within 5 minutes or a dynamic route within 30 seconds, the cached version is used. Otherwise, a request to the server is made for the new HTML/RSCP. Additionally, the cache is only stored for the user's current session and is cleared if the user closes the tab or refreshes the page.
  • Revalidation: Revalidating the Router Cache can be done on demand, similar to the Data Cache, by using revalidatePath or revalidateTag. This action also revalidates the Data Cache.
  • Opting Out: There is no direct way to opt out of the Router Cache. However, with the various methods available for revalidation, opting out is not a significant concern.

The Router Cache provides a notable advantage in page loading speeds but can lead to unexpected behavior if not understood correctly.

route-cache-image

Conclusion

Understanding the intricacies of Next.js caching, with its various mechanisms and interactions, can be challenging. However, this article aimed to shed light on how these caches operate and interact. While the official documentation suggests that knowledge of caching is not essential for productive use of Next.js, understanding its behavior can greatly assist in configuring settings that best suit your application.

By grasping the workings of Request Memoization, Data Cache, Full Route Cache, and Router Cache, you gain a deeper insight into optimizing your Next.js application for improved performance. Each cache plays a crucial role, and knowing how to leverage them effectively can enhance the user experience and streamline your development process.

Let's develop your ideas into reality