Every post so far has assumed nothing was cached. That was on purpose — you can't reason about an optimization until you understand the thing it optimizes. Now we turn it on.

This post is different in one way from the rest of the series. Everywhere else, you came in with a working model that was slightly wrong, and the job was to correct it. Caching isn't like that. Almost nobody has a wrong model of Next's caching — they have no model, just a revalidate they copied off Stack Overflow once and a vague dread when a page won't update.

So we're not going to correct anything. We're going to build the model from the ground up, starting with something you've definitely seen with your own eyes, and working down to the machinery underneath it.

By the end there are four layers. We'll meet them one at a time, each one explaining a thing the previous one couldn't.

I hated saying 'It's probably caching issue' when things did not work as I expected.


Start with what you've seen: the page that was already there

Navigate around a Next app with the Network tab open. Click into a product, hit back, click into another, hit back. Watch the requests.

Going back fires nothing. The previous page is just… there, instantly, including whatever you'd scrolled to or typed into it.

That's a cache. The first one, and the one closest to you: the browser is holding pages it already has.

When you navigate with <Link>, Next keeps the RSC payload for routes you've visited — in memory, in the tab. Go back, and it replays the payload it already has instead of asking the server again. This is why back/forward is instant, and it's the mechanism behind a claim from the navigation post: layouts don't re-render on navigation because the router is handing you a tree it kept, not fetching a new one.

Call this the Router Cache. It lives in the browser, it's the shortest-lived of everything we'll meet, and it's the one you have the least control over.

It's also the first liar. Here's the trap, and you may have hit it: you change something, the change saves, the server has the new data — and the page still shows the old thing until you refresh. Nothing is broken on the server. The browser is just showing you a payload it's still holding in memory. We'll come back to how that gets cleared; for now, just note that "stale until refresh, then fine" has a specific cause, and it's this layer.

So: one cache down, and we found it without reading a single doc. Just watched the Network tab.


Go one level down: the page that never hit your code

Now do a fresh load — type a URL, hit enter — on a marketing page or a blog post. Look at the timing. It's fast in a way a database query can't be, because no database was queried. No component of yours ran at all.

That page was built once, ahead of time, and saved. You're being handed a file.

This is the Full Route Cache — the prerendered HTML and RSC payload from the build step we traced in What Runs When. The .html and .rsc files on disk. For a route with no per-request data, that work happened once at build and every visitor gets the saved copy.

One layer up we had the browser holding pages it fetched. Here, one level deeper, the server is holding pages it built. Same idea — reuse instead of recompute — but a different place, a different lifetime, and a different way to clear it.

"But my content changes. I can't build it once and freeze it forever."

Right, and this is where most people's understanding falls off, so we'll go slowly.

You don't have to choose between "built once, frozen" and "rebuilt on every request." There's a middle setting: build it, serve the saved copy, and rebuild it in the background every so often so it stays roughly fresh without ever making a user wait.

That middle setting is called ISR — Incremental Static Regeneration. And here's a correction to something I glossed earlier in this series: ISR is not a legacy pattern. In Next 16 it was renamed and made much more capable, and it solves a problem you hit the moment your site is real: you have more URLs than you can afford to build.

Ten thousand products. You are not prerendering ten thousand pages on every deploy. So Next splits the work:

  • Build the pages worth building ahead of time — the popular ones, listed in generateStaticParams.

  • For every other URL, build a generic App Shell — the layout and frame, with a gap where the specific content goes.

Now a visitor asks for a product you didn't prebuild. Instead of making them wait for a fresh render, Next hands over the App Shell instantly — frame on screen, skeleton where the product will be — then fills in the real product in the background. The next person who asks for that same URL gets the now-complete page from the cache. The route built itself, on demand, once, triggered by the first person who wanted it.

There's a structural rule that makes this possible, and you've already seen it in the property page without knowing why it was written that way:

export default function PropertyPage(props: PageProps<'/[id]'>) {
  return (
    <Suspense fallback={<PropertySkeleton />}>
      <PropertyBody params={props.params} />   {/* await params INSIDE */}
    </Suspense>
  )
}

Don't await params at the top of the component. Pass the params promise down into a <Suspense> boundary and await it in there. Here's the reasoning, step by step: awaiting params at the top means the whole component depends on knowing which URL this is — so Next can't build anything until it knows. Awaiting it inside a boundary means everything above the boundary — the App Shell — can be built without knowing the URL at all. The shell becomes URL-independent, which is exactly what lets Next serve it instantly for a product it's never seen.

If you came from the Pages Router: this is fallback: true, getStaticPaths is now generateStaticParams, and getStaticProps with revalidate is now the use cache + cacheLife combo we're about to meet.

Don't try to build it all, it's not worth it.


Go deeper: caching a piece instead of a whole page

The Full Route Cache is all-or-nothing per route. But most real pages aren't all-or-nothing. A property page has details that barely change (address, description, photos) sitting right next to a price that changes hourly. You can't freeze the whole route — the price would go stale — but you don't want to rebuild the whole thing every request either, when only the price is live.

So you need to cache part of a page. That's the next layer down, and it's the one you actually reach for by hand: the Data Cache, driven by the use cache directive.

async function getProperty(id: string) {
  'use cache'
  cacheLife('property')       // how long it stays fresh
  cacheTag(`property-${id}`)   // a label, so you can clear it precisely later
  return getAllPropertyDetails(id)
}

use cache stores a result and reuses it across requests — the next visitor gets the stored copy instead of recomputing. That's the difference from the Full Route Cache above it: that one caches whole routes at build time, this one caches pieces, and can do it at runtime.

The one detail that will burn you on deploy

use cache has a default storage location, and on a serverless host — Vercel, most likely — that location is the memory of one server instance, and it is not shared.

Walk through what that means, because it's the single most expensive misunderstanding in the whole model. You cache your search results with use cache. Locally, one process, it works perfectly — second search is instant. You deploy. Now there are many instances. Request A caches its result in instance 1's memory. Request B lands on instance 2, which has never seen it, and recomputes. Your cache "works" maybe a third of the time and you can't figure out why.

The fix is one word:

async function getSearchResults(query: SearchQuery) {
  'use cache: remote'          // durable, shared across instances
  cacheLife('search')
  cacheTag('search')
  return searchPropertiesApi(query)
}

use cache: remote stores in the platform's shared, durable cache instead of one instance's memory. The rule: anything built at build time can stay plain use cache (it's a file, it's durable); anything computed per-request that you want shared needs : remote.

The same directive, moved up, does more

Here's the part that turns use cache from a data cache into the main lever of the whole system. What it caches depends on where you put it:

  • On a function → it caches the return value. The JSX that uses it still re-renders every time.

  • On a component → it caches the rendered output — the actual RSC payload for that whole subtree. Every child render, every .map, every bit of formatting: done once, replayed.

  • On a file → it caches the whole segment's render.

Read that top to bottom and a strategy appears. You don't push use cache down onto your data helpers to save a fetch. You push it up onto components and subtrees, so the server does the rendering work once and replays it. "Render as much on the server as possible" isn't a slogan — it's literally moving the directive up the tree.

And it composes, because of one rule: a cached component's children and other JSX slots are passed through without entering its cache key. So you cache the stable shell and thread the live parts through it:

async function CachedProperty({ id, pricingSlot }: {
  id: string
  pricingSlot: React.ReactNode    // a live island, passed through
}) {
  'use cache'
  cacheLife('property')
  cacheTag(`property-${id}`)
  const property = await getProperty(id)
  return <PropertyContent property={property} pricingSlot={pricingSlot} />
}

The property render is cached for a long time and cleared by tag. The pricingSlot — a separate <Suspense> island passed in from above — streams fresh on its own short cycle. One cached, one live, interleaved in a single tree. That works only because props pass through as slots without becoming part of the key. (Imports would pull the code in; props don't. That's the same boundary distinction from the two-graphs post, showing up again.)

Two rules that will each cost you a debugging session if you don't know them:

  • A cached component's props are its cache key, and they're serialized with the strict RSC rules — primitives and plain objects only. Pass a Date or a class instance or a function as a keying prop and it breaks. (Pass-through children/slots are exempt — they're not keys.)

  • Keep notFound() outside a cached component. Return null from the cached part; throw not-found in the caller.


The bottom layer: the same read, many times, one render

We've gone from the browser, to the built route, to the cached piece. One layer left, and it's the smallest and lowest.

Inside a single render, the same data often gets read more than once. Your layout needs the current user. So does the page. So does generateMetadata. Three components, three calls to getUser(), and naively, three database hits for one page load.

The bottom layer collapses those into one. It's React's cache:

import { cache } from 'react'

const getUser = cache(async (id: string) => {
  return db.users.findById(id)
})

Now getUser(1) runs its body once per render, no matter how many components call it. The rest get the stored result.

This is the layer most likely to be confused with the one above it, so here's the sharp line. The docs describe react cache as "a useMemo at the function level" — and that comparison is the trap, because it makes it sound persistent. It isn't. Its entire world is one render, one request. When the request ends, the table is gone. It saves work within a single page load; it saves nothing for the next visitor. That's use cache's job, one layer up. Same word, completely different lifetime — which, by now, is the most predictable thing in this whole framework.

Two things that bite:

  • It caches errors too — if the read throws, every caller in that render re-throws it.

  • Each cache() call is its own cache — wrap the function once, export it, import that everywhere. cache(fn) in two files gives you two unrelated caches.

One companion piece, new in React 19.2: cacheSignal(). When you cache() an expensive fetch, cacheSignal() gives you an abort signal that fires when that render's lifetime ends — render finished, aborted, or dropped. Hand it to fetch, and if React abandons the render (user navigated away mid-stream), the fetch aborts instead of running to completion for a page no one will see.

import { cache, cacheSignal } from 'react'

const getChartData = cache(async (id: string) => {
  const res = await fetch(`/api/chart/${id}`, { signal: cacheSignal() })
  return res.json()
})

The switch that turns caching on — and the error it introduces

One piece of plumbing connects all of this, and you have to opt in:

// next.config.ts
const nextConfig = { cacheComponents: true }

Flipping that flag inverts the default. Without it, Next tried to guess what to cache. With it, everything is dynamic — runs per request — unless you either prerender it or wrap it in use cache. No more guessing; you decide, explicitly.

The catch, and you'll meet it on your first build: any per-request data — cookies(), headers(), searchParams, an uncached fetch — that is not inside a <Suspense> boundary becomes a build error. Next is refusing to guess whether that page is static or dynamic; you have to say.

The tool for saying "this part is dynamic, on purpose" is connection():

import { Suspense } from 'react'
import { connection } from 'next/server'

async function LiveBanner() {
  await connection()               // everything after this is per-request
  const now = Date.now()
  return now > promoEnd ? <Normal /> : <Promo />
}

export default function Page() {
  return (
    <Suspense fallback={<BannerSkeleton />}>
      <LiveBanner />
    </Suspense>
  )
}

The first place this bites is time. Under cacheComponents, Date.now() or new Date() before any other dynamic read throws a build error — because reading the clock during a prerender would bake the build time into a supposedly static page. Three ways out, best first: move it to a Client Component if it's for display; wrap it in use cache if a build-time value is actually fine; or await connection() inside a Suspense boundary if it genuinely must be per-request.


Now put all four together

We built them bottom-up as we found them. Here they are as one picture, and now the names have meaning instead of being jargon:

  • Request Memoization (react cache) — one read reused within a single render. Dies with the request.

  • The Data Cache (use cache) — a computed value or rendered subtree reused across requests. The one you place by hand.

  • The Full Route Cache — whole prerendered routes, from the build. ISR is how this one stays fresh and scales past what you can build.

  • The Router Cache — visited pages held in the browser, so navigation is instant.

Four caches, four lifetimes, four ways to clear them. Which brings us to clearing.

Invalidation, one story per layer

"How do I clear the cache" has four answers, and using the wrong one is why a cache feels haunted:

  • Request Memoization — you don't clear it; it's gone when the render ends. If it feels stale, your tree fetches the same thing in two separate render trees — consolidate.

  • Data Cache — by time (cacheLife) or by tag. Tags are the precise tool: a price change fires one tag and touches nothing else.

  • Full Route Cache — on deploy, on a time window, or on-demand by tag/path.

  • Router Cache — the shortest-lived; a mutation that returns a fresh tree, or a hard navigation, clears it.

The tag mechanism is most of a real invalidation strategy. Tag caches by the thing that changes, and clear exactly that when it changes:

// app/api/revalidate/route.ts — guard it with a secret
export async function POST(req: Request) {
  const { tags } = await req.json()
  for (const tag of tags) revalidateTag(tag, 'max')
  return Response.json({ revalidated: tags })
}

Two revalidation functions, and picking wrong is a common mistake: revalidateTag(tag, 'max') in route handlers (the bare single-argument form is deprecated and errors in TypeScript now — pass 'max' or a cacheLife profile), and updateTag() inside Server Actions. Tag by property-<id>, pricing-<id>, search, and each clears independently — which is what makes a long cacheLife safe. The duration is just a backstop; correctness comes from firing the tag on the change.


Which layer is lying to you

You now have enough to debug caching by symptom instead of by guessing. A page shows something wrong — walk it:

Stale after a data change? → Data Cache or Full Route Cache. Did the mutation fire the right tag? Is the data behind use cache with a long life and no tag? Then it's stale by design until the window passes.

Worked locally, broken on the deploy? → Data Cache, the serverless memory trap. Plain use cache on a runtime value. Switch to use cache: remote.

Stale only until you refresh, then fine? → Router Cache. The server is right; the browser is replaying a payload it kept. A mutation returning a fresh tree, or a reload, clears it.

Same query hitting the database several times in one render? → Request Memoization. Wrap the reader in react cache, import the one wrapped copy.

Build failed with a dynamic-data error? → Not a lie, a requirement. Uncached request data outside <Suspense> under cacheComponents. Wrap it, cache it, or connection() it.

Four layers, four failure signatures. The fix was never the hard part. Knowing which of the four you were looking at was — and now you do, because you watched each one appear instead of being handed a list.


If this helped, or if I got something wrong — find me on LinkedIn or my site. I read everything.