Caching pain points and Next.js 15 overview
There are only two hard problems in computer science: naming things, cache invalidation, and off-by-one errors. Maybe you've heard that joke before because another problem with programming is that we only have one joke and it's not very funny. But there is some truth in the original joke and that's the fact that caching can be extremely difficult, and the Next.js JavaScript framework is the poster child for these modern challenges.
A few weeks ago Vercel released Next.js 15 and it comes with all kinds of awesome new features like tools to automatically migrate code, a fast Turbopack dev server, and a new API to run code in the background after a request is complete. But the biggest change is that they totally overhaul the way caching works, and to be honest it's pretty awesome, and that's what we'll look at.
Default caching behavior and the motivation for change
By default everything is cached in Next.js: a server component that defines a web page and also any calls you make to the fetch function would be cached by default to try to optimize performance by preventing the server from doing extra work and rendering the same page over and over again.
The problem is that in some cases you might have multiple data fetching operations to render a page. You might want some of them to be cached but you might want others to be revalidated on every request, and opting out of the cache could be pretty annoying. There are a few different ways to do it, like adding the cache: 'no-store' option to fetch, or by exporting this force-dynamic variable, or by setting the cache-control headers manually. It's kind of a mess and easy to run into problems where your data is not updating because it's being cached and you have no idea why.
The good news is that in Next 15 things are becoming far more intuitive with a new API called Dynamic IO, which allows you to cache or not cache anything while also simplifying your code and making it far more explicit.
Enabling experimental Dynamic IO and demo setup
To demonstrate how this works let's look at some code. I'm here in a brand new Next.js project, but currently this feature is experimental, so let's enable it by adding Dynamic IO to the next config. As of today we'll also need to be on the Next.js canary version, which you can update to with these commands. Optionally you'll notice that I'm using Deno here instead of Node.js because they have some cool utilities that we'll look at later, but that's totally optional.
To simulate data I created a utility that has a separate function for getProduct and getPrice. I'm using the library Faker.js to generate some fake data, which you can think of as data you would fetch from a database. Interesting backstory on Faker.js: a few years ago the original developer intentionally corrupted this project to break a bunch of people's apps on purpose as a statement against open source abuse and lack of funding. It actually made it into the mainstream news and was the story that motivated me to start the Code Report series on the main channel. Ultimately it didn't work out too well for him and they took over the project and now it's maintained by someone else. In this demo Faker.js is really useful because it makes it easy to tell which data is cached and which is not.
Making functions async, Suspense, and opting for dynamic rendering
From there I'm creating a page on the store route inside of which we have a server component. Inside that component we get the product and price synchronously and then render it in the HTML. If we serve the application you'll notice in the browser that every time I refresh the page I'm getting a new product price and description. That's because currently this page is not cached and Next.js will render every time we refresh.
Now let's make this a little more realistic by going back into our db file, then make our data fetching functions async to make it even more realistic. I'll bring in this delay function from Deno to provide a one-second delay to simulate latency in the database. Then from there I'll go back into the product page and make this server component async and add awaits to our function calls. Now if we go back into the browser you'll notice that it renders but we also get this error. What this error is telling us is that we need to make a choice: we either need to cache or pre-render this page, or we need to make it dynamic explicitly by wrapping it in a Suspense boundary.
Let's first assume that we want this page to be dynamic and refetch the data on every request. To clear the error the first thing we need to do is wrap it in Suspense, and we can do that easily in Next.js by simply creating a loading file that exports a component. This is basically the equivalent of using Suspense in the parent layout. Now if we go back to the application you'll notice that the error is cleared and we get fresh data on every request.
use cache directive, cacheLifetime, and cacheTag invalidation
But now let's assume that we don't want dynamic data and would rather cache our data. We can easily accomplish that by simply adding the use cache directive to the top of this page. use cache is a Next.js specific feature and not directly related to React.js, but what you'll notice is that when we go to the browser now we get fresh data on the first render but then get that same data every time we refresh the page, and that will continue until the cache expires.
That's a good question: when does the cache expire? Well currently the default is an infinite cache that's revalidated every 15 minutes, which you'd want for content that doesn't change on a regular basis like your about page. But what if we want to change the caching behavior? There's a new cacheLifetime function that we can import from Next.js which we can then use inside the body of the function to change the caching behavior to a variety of different defaults. In this case we might only want to cache it for a few hours if the product description or price has the potential to change on a daily basis. If these defaults aren't good enough for you, you can fully customize them to your own values in the next config.
But if that's still not good enough for you you can also invalidate the cache on demand, which is made possible with this cacheTag function. You provide the function with a key that's used to identify a cached value, and that means that at some point — maybe in a couple minutes or a couple years — you can invalidate the cache when the underlying data changes. Typically that would happen when you update a record in the database that would impact what's presented to the user in the UI.
Fine-grained caching patterns, Suspense composition, and final thoughts
At this point we've only looked at ways to cache an individual route, but this use cache directive provides us with far more flexibility than that. For example we might have multiple components in a single file, and if we put use cache at the top of that file all the components will inherit the same behavior. If that's not desired though we can move the directive inside of individual components to choose exactly which ones we want to opt in.
The most powerful feature though is its ability to cache individual functions and server actions. If we go back into the db.ts file we can actually implement different caching behavior for each function. Maybe our product description is static and almost never changes, so let's put the use cache directive there. In contrast the price might be highly dynamic, so let's leave that one as is. Now let's go back to our component and figure out how to use multiple levels of caching on a single page. First make sure to remove the use cache directive from the top, then we'll extract the price UI and data fetching logic into its own component.
This component will fetch and render the price in the UI just like before, but the key difference is that we wrap that component in Suspense in the parent. What this will do is allow the rest of the page to render immediately while only the price remains suspended in a loading state. That's awesome because otherwise we would have to wait for the price before rendering anything on the page. As you can see in the demo the user gets most of the content like the product description while the price finishes rendering in the background, and that's especially powerful on things like dashboards where you're loading multiple different data sources all of which have different degrees of latency.
Congratulations. Now that you understand use cache and Suspense that's everything you'll need to memorize for caching in Next.js 15. It's usually a good thing when an API decreases in complexity, but as of today Dynamic IO is still experimental and caching is still hard. I think this is a step in the right direction. However, in a big project this code might get kind of crazy: your codebase will have use client, use server, and now use cache everywhere. Then you might implement the new using keyword in JavaScript to use the new React use hook, then combine them all to create your own use-using directives and hooks, at which point your code will be utterly useless.
But one other cool thing about Next 15 is that it now also supports React 19 and the React compiler, which eliminates the need for annoying hooks like useMemo and useCallback. It's another step in the right direction but still very experimental today. Most importantly these features are designed to provide job security by giving React developers a never-ending supply of legacy code to update. If React 19 ever comes out someday expect a full course on Fireship IO and when that day comes you'll already have access to it if you're a lifetime member. Thanks for watching and I will see you in the next one.