DEV Community

Peter Jacxsens
Peter Jacxsens

Posted on

Router cache and request memoization

We've covered full route cache, data cache and revalidation. Next, we look into router cache and request memoization.

Router cache

Router cache is client side cache, it lives in the browser's memory and consists of rsc payload. The goal of router cache is instantaneous navigation between pages. It does that by caching already visited pages and by prefetching pages that we might visit (the <Link /> component) and saving those in cache.

Each route is split into route segments: layouts (layout.tsx), loading states (loading.tsx) and pages (page.tsx). It's these route segments that actually get cached in the router cache. But only partly:

  • Layouts are cached and reused on navigation (partial rendering).
  • Loading states are cached and reused on navigation for instant navigation.
  • Pages are not cached by default, but are reused during browser backward and forward navigation. [...]

I don't really know how a page can be reused but not cached.

Duration

I can't improve upon what the Next docs say about this, so I'm just going to copy it:

The cache is stored in the browser's temporary memory. Two factors determine how long the router cache lasts:

  • Session: The cache persists across navigation. However, it's cleared on page refresh.
  • Automatic Invalidation Period: The cache of layouts and loading states is automatically invalidated after a specific time. The duration depends on how the resource was prefetched, and if the resource was statically generated:
    • Default Prefetching (prefetch={null} or unspecified): not cached for dynamic pages, 5 minutes for static pages.
    • Full Prefetching (prefetch={true} or router.prefetch): 5 minutes for both static & dynamic pages.

While a page refresh will clear all cached segments, the automatic invalidation period only affects the individual segment from the time it was prefetched.

Next docs

Note: this comes from Next 15 docs, more on this is a bit.

Invalidation

Again, from the docs:

There are two ways you can invalidate the Router Cache:

  • In a Server Action: - Revalidating data on-demand by path with (revalidatePath) or by cache tag with (revalidateTag) - Using cookies.set or cookies.delete invalidates the Router Cache to prevent routes that use cookies from becoming stale (e.g. authentication).
  • Calling router.refresh will invalidate the Router Cache and make a new request to the server for the current route.

Next docs

Note: in tests in the previous chapters, I had issues with router cache persisting after revalidation from a server action. So don't take this for granted.

Some closing notes

You may be thinking I took it a bit easy in this chapter by just copy-pasting. True, it was easier but there are some reasons why I did it.

  • Next explained it well. There was nothing for me to clarify.
  • It's hard to write tests for this so I couldn't really offer any added value.

But the main reason is something else. When I started writing this series I was carefully studying the docs. But, when I started to write this chapter, I suddenly couldn't find the docs on router cache anymore.

What happened? Next updated the docs on caching (v16.2.1 at 25/03/2026). Everything we covered in the previous 9 chapters is now considered "Caching and Revalidation (previous model)". The new model is "cache components".

On top of that, Next renamed router cache to client cache (we approve) and also changed how it works. The parts from the docs I cited above are from Next 15.

Lastly, I'm not sure there is that much to say about router cache. I just works in the background. We will come back to client cache later because at this point I don't know nothing about cache components yet.

Request memoization

It's very common to need data from a particular fetch in multiple components within a single route. Request memoization removes the need to pass props or use context by caching the result of the fetch and reusing it during the render pass.

/page.tsx
  Component1
    Fetch 1
    Fetch 2
  Component2
    Fetch 1
  Component3
    Fetch 2
Enter fullscreen mode Exit fullscreen mode

In the above example, only two fetches will be made, fetch 1 and fetch 2. When Next hits the same fetch again, it will reuse the memoized result.

How does this differ from data cache then?

  1. When Next encounters a fetch, it will first check request memoization. If there is a match it will return the match and not check the data cache. If there is no match in the memo cache, then Next will check the data cache and put this match in the memo cache.
  2. Request memoization has a lifespan of the request cycle. So, a request is made, fetches are memoized and reused if needed, the route renders and the request memoization cache is purged.

So, the big point of memoization is that you can be 100% confident in making the same fetch over and over again in a singe route. It will not affect performance or cause higher load on some server endpoint.

Some notes

  • Memoization is a server feature. It only works in server components.
  • Memoization only applies to the GET method in fetch requests.
  • Once a request lifecycle has ended, memoization is removed. That's were data cache will takes over. It persists between routes.
  • Memoization doesn't work in route handlers (route.ts), only in the React component tree.

Finally, memoization happens for fetches that have the same url and options. Maybe we should write a little test for this?

Little test

Note: the examples are available on github.

We're going to create an api endpoint and then call it multiple times inside a dynamic page. Why do we need an endpoint? So I can log when it gets called. Why dynamic? In a static route I would need an external server (you can't run and build at the same time) and that would complicate this example. Here is the route handler:

// app\memoization\api\route.ts

const user = {
  id: 1,
  name: 'Peter',
};

export const dynamic = 'force-dynamic';

export async function GET() {
  console.log('Memoization/api called');
  return Response.json(user);
}
Enter fullscreen mode Exit fullscreen mode

A getUser function that calls this endpoint. Note, we pass fetch options as param:

// app\memoization\getUser.ts

type UserT = {
  id: number;
  name: string;
};

export async function getUser(fetchOptions: RequestInit) {
  console.count('Running getUser');
  try {
    const res = await fetch(
      'http://localhost:3000/memoization/api',
      fetchOptions,
    );
    const user: UserT = await res.json();
    return user;
  } catch (error) {
    console.error(error);
    return null;
  }
}
Enter fullscreen mode Exit fullscreen mode

A <User /> component that calls getUser and renders name:

// components\memoization\User.tsx

import { getUser } from '@/app/memoization/getUser';

export default async function User({
  fetchOptions,
}: {
  fetchOptions: RequestInit;
}) {
  const user = await getUser(fetchOptions);
  if (!user) return <div>No user found</div>;
  return <div>{user.name}</div>;
}
Enter fullscreen mode Exit fullscreen mode

And finally a route:

// app\memoization\page.tsx

import User from '@/components/memoization/User';

export const dynamic = 'force-dynamic';

export default async function Page() {
  return (
    <>
      <User fetchOptions={{}} />
      <User fetchOptions={{}} />
      <User fetchOptions={{}} />
      <User fetchOptions={{}} />

      <User fetchOptions={{ cache: 'no-store' }} />
      <User fetchOptions={{ cache: 'no-store' }} />
      <User fetchOptions={{ cache: 'no-store' }} />
      <User fetchOptions={{ cache: 'no-store' }} />

      <User fetchOptions={{ cache: 'force-cache' }} />
      <User fetchOptions={{ cache: 'force-cache' }} />
      <User fetchOptions={{ cache: 'force-cache' }} />
      <User fetchOptions={{ cache: 'force-cache' }} />

      <User fetchOptions={{ next: { revalidate: 1000 } }} />
      <User fetchOptions={{ next: { revalidate: 1200 } }} />
      <User fetchOptions={{ next: { revalidate: 1400 } }} />
      <User fetchOptions={{ next: { revalidate: 1600 } }} />

      <User fetchOptions={{ next: { tags: ['user-1'] } }} />
      <User fetchOptions={{ next: { tags: ['user-1'] } }} />
      <User fetchOptions={{ next: { tags: ['user-2'] } }} />
      <User fetchOptions={{ next: { tags: ['user-2'] } }} />
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

We log from getUser and from the route handler. Take a guess, what happens? Remember, request memoization is automatic for fetches that have the same url and options.

For me, the result was a bit surprising:

Running getUser: 1
...
Running getUser: 20
Memoization/api called
Enter fullscreen mode Exit fullscreen mode

Even though the getUser function was invoked 20 times, the underlying API was only hit once. There is an interesting quirk here: React's memoization usually requires the URL and all options to be identical. Even though we varied the cache and next.revalidate options in our test, React (or Next.js's patched version of it) deduplicated them anyway. Great for performance!

Bonus points: Here's a little question: how many of these fetches ended up in data cache?

Again, a bit surprising, we have two entries in cache:

// .next\cache\fetch-cache\hash
{"kind":"FETCH","data":{"headers":{"connection":"keep-alive","content-type":"application/json","date":"Mon, 30 Mar 2026 14:10:04 GMT","keep-alive":"timeout=5","transfer-encoding":"chunked","vary":"rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch"},"body":"eyJpZCI6MSwibmFtZSI6IlBldGVyIn0=","status":200,"url":"http://localhost:3000/memoization/api"},"revalidate":31536000,"tags":[]}
Enter fullscreen mode Exit fullscreen mode
// .next\cache\fetch-cache\hash
{"kind":"FETCH","data":{"headers":{"connection":"keep-alive","content-type":"application/json","date":"Mon, 30 Mar 2026 14:10:04 GMT","keep-alive":"timeout=5","transfer-encoding":"chunked","vary":"rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch"},"body":"eyJpZCI6MSwibmFtZSI6IlBldGVyIn0=","status":200,"url":"http://localhost:3000/memoization/api"},"revalidate":1000,"tags":[]}
Enter fullscreen mode Exit fullscreen mode

One with revalidate 1000, the other 31536000 (default = 1 year). Can we explain this?

  • The first 8 fetches have implicit or explicit no cache policies, so they don't get cached in data cache.
  • The first fetch with force-cache should then be the one above with 1 year revalidate. The next 3 with force-cache will be memoized.
  • This: <User fetchOptions={{ next: { revalidate: 1000 } }} /> is obviously the second one we found in cache. But the next 3 with values 1200, 1400 and 1600 are absent. Not sure about this.
  • The last four with tags user-1 and user-2 are absent. Maybe they hit memo cache, matched and did not proceed to check data cache? I'm not sure.

It gets tricky but in the end it doesn't really matter. Some final pieces.

Cache and unstable_cache

React automatically performs request memoization for the fetch API. If you aren't using fetch, you can manually implement request memoization by wrapping a function with cache:

export const getItem = cache(async (id: string) => {
  const item = await db.item.findUnique({ id });
  return item;
});
Enter fullscreen mode Exit fullscreen mode

Also, note that you're not limited to DB or endpoint queries. It's also possible to cache heavy computational functions. But, beware here, cache is for request memoization, not for data cache.

To manually add items to data cache, Next provides the unstable_cache function. However, Next warns that this API was replaced with use cache which we will cover later.

Closing

This mostly concludes caching in Next - the old model. Before we start with the new model - use cache - I have some loose ends to tie and some other things to investigate. We'll do that first and then move on to the new model.

If you want to support my writing, you can donate with paypal.

Top comments (0)