
Understanding the Next.js Caching Layers: Request Memoization, Data Cache, and Full Route Cache
Most confusion about caching in Next.js comes from treating it as one thing. You change a record in the database, reload the page, and the old value is still there. Was it the fetch call? The page? The browser? The answer depends on which of several independent caches is holding the data, and each one has its own scope, lifetime, and way of being cleared.
Next.js has four places where work gets reused: request memoization, the Data Cache, the Full Route Cache, and the client-side router cache. They sit at different points in the path from your data source to the user's screen. Once you know what each one stores and when it's invalidated, "why is this stale?" becomes a quick diagnosis instead of a guessing game.
This post walks through each layer in the order a request meets them, shows how to opt in and out, and explains how they interact. It describes Next.js 16 with its default configuration. At the end, there's a section on how the same ideas map onto the newer Cache Components model if you've enabled cacheComponents.
The Big Picture
Here's the path a request takes, from the browser down to your data, with the cache at each step:
| Layer | Where it lives | What it stores | How long |
|---|---|---|---|
| Client cache (router cache) | Browser memory | RSC payloads for visited and prefetched routes | Session, subject to stale times |
| Full Route Cache | Server (disk or platform storage) | Rendered HTML and RSC payload for a route | Until revalidated or redeployed |
| Request memoization | Server memory, per request | Return values of fetch and React.cache functions | One render pass |
| Data Cache | Server (disk or platform storage) | Responses from fetch calls that opt in | Until revalidated |
A useful way to remember them:
- Memoization avoids doing the same work twice within one request.
- The Data Cache avoids calling the same API again across requests.
- The Full Route Cache avoids rendering the same page again across requests.
- The client cache avoids asking the server again during client-side navigation.
Let's go through them from the inside out.
Request Memoization
Request memoization is the narrowest cache. It exists only while the server renders one request, and it's there so you can fetch data where you need it without worrying about duplicates.
Imagine a layout, a page, and generateMetadata that all need the current product:
// app/products/[id]/page.tsx
import type { Metadata } from "next";
type Product = { id: string; name: string; description: string };
async function getProduct(id: string): Promise<Product> {
const res = await fetch(`https://api.example.com/products/${id}`);
if (!res.ok) throw new Error("Failed to load product");
return res.json();
}
export async function generateMetadata({
params,
}: {
params: Promise<{ id: string }>;
}): Promise<Metadata> {
const { id } = await params;
const product = await getProduct(id);
return { title: product.name };
}
export default async function ProductPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const product = await getProduct(id);
return (
<article>
<h1>{product.name}</h1>
<p>{product.description}</p>
</article>
);
}
getProduct is called twice, but only one HTTP request goes out. Next.js memoizes fetch calls that use GET with the same URL and options during a single render pass, across layouts, pages, generateMetadata, generateStaticParams, and any Server Component in the tree. The second call gets the result of the first.
Key facts:
- Scope: one server request. The next request starts with an empty memo.
- Applies to:
GETrequests made withfetch.POSTrequests aren't memoized. - Doesn't apply in Route Handlers, because they're not part of the React component tree.
- Independent of the Data Cache. A
fetchthat isn't cached at all is still memoized within the request. - Opt out by passing an
AbortControllersignal, though there's rarely a reason to.
Memoizing database calls with React.cache
Memoization is automatic for fetch, but not for database queries or SDK calls. For those, wrap the function in React's cache:
// lib/data/user.ts
import { cache } from "react";
import { db } from "@/lib/db";
export const getUser = cache(async (id: string) => {
return db.user.findUnique({ where: { id } });
});
Now any number of Server Components can call getUser(id) during the same request and the query runs once per unique id. This is the pattern that lets you skip prop drilling: each component asks for what it needs, and memoization removes the duplicate work.
React.cache has the same lifetime as fetch memoization, a single request. It doesn't share results between users or between requests, so it's safe for per-user data.
The Data Cache
The Data Cache is where fetch responses can be stored across requests, and across users. If memoization prevents calling an API twice in one render, the Data Cache prevents calling it on every visit.
Since Next.js 15, fetch is not cached in the Data Cache by default. You opt in per request:
// Cache this response until it is revalidated
await fetch("https://api.example.com/categories", { cache: "force-cache" });
// Cache it, but refresh at most once an hour
await fetch("https://api.example.com/products", {
next: { revalidate: 3600 },
});
// Cache it and tag it for on-demand invalidation
await fetch("https://api.example.com/posts", {
cache: "force-cache",
next: { tags: ["posts"] },
});
// Never cache, always fetch fresh
await fetch("https://api.example.com/stock", { cache: "no-store" });
When a cached fetch runs, Next.js looks for a matching entry. A request matches on its URL, method, headers, and body. If there's a fresh entry, it's returned without touching the network. If there isn't, Next.js makes the request and stores the result. Only responses with a 200 status are stored.
How long entries last:
force-cachewithoutrevalidate: until something invalidates it.next.revalidate: n: time-based, stale-while-revalidate. Afternseconds, the next request still gets the cached response, and a fresh one is fetched in the background for later requests.- On demand:
revalidateTag(for tagged entries) orrevalidatePath(for entries used by a route). The details are in the post on on-demand revalidation.
The Data Cache is persistent. It survives server restarts and, depending on your hosting, even new deployments. That's powerful, and it's also why a value can stay stale long after you expected it to change: a redeploy doesn't necessarily clear it.
Caching things that aren't fetch
Database queries don't go through fetch, so they can't use force-cache. In this caching model the tool for that is unstable_cache:
// lib/data/categories.ts
import { unstable_cache } from "next/cache";
import { db } from "@/lib/db";
export const getCategories = unstable_cache(
async () => db.category.findMany({ orderBy: { name: "asc" } }),
["categories"],
{ revalidate: 3600, tags: ["categories"] },
);
The second argument is a key prefix, and the options mirror the fetch options. Despite the name, it's been the standard approach for years. In the Cache Components model it's replaced by the "use cache" directive, covered in the "use cache" guide.
The Full Route Cache
The Full Route Cache stores the output of rendering, not just the data. For routes that can be rendered ahead of time, Next.js renders them at build time (or during revalidation) and keeps two artifacts together:
- The HTML, used for the initial page load.
- The RSC payload, a compact description of the Server Component tree, used for client-side navigation and to hydrate Client Components.
When a request comes in for a cached route, Next.js serves those artifacts without running your components at all. That's what makes static pages so fast and cheap.
Static vs dynamic routes
Whether a route ends up in the Full Route Cache depends on what it does while rendering. A route is rendered at request time, and skips this cache, when it uses something that's only known per request:
cookies(),headers(), ordraftMode()- the
searchParamsprop connection()fromnext/server- a
fetchwithcache: "no-store"ornext: { revalidate: 0 } export const dynamic = "force-dynamic"orexport const revalidate = 0
Dynamic params like [id] are known per request too, unless you list them with generateStaticParams, in which case those pages are prerendered at build time.
The build output tells you which category each route landed in:
Route (app)
┌ ○ /
├ ○ /about
├ ● /blog/[slug]
│ ├ /blog/hello-world
│ └ /blog/caching-explained
└ ƒ /dashboard
○ (Static) prerendered as static content
● (SSG) prerendered as static HTML (uses generateStaticParams)
ƒ (Dynamic) server-rendered on demand
○ and ● routes live in the Full Route Cache. ƒ routes are rendered for every request.
One subtle point: a plain fetch with no cache option, in a route that is otherwise static, runs once during next build. Its result is baked into the prerendered HTML. It isn't in the Data Cache, but you'll still see the same value until the route is revalidated or rebuilt, because the whole page is cached.
Route segment config
You can push a route in either direction with exports from page.tsx or layout.tsx:
// app/pricing/page.tsx
// Re-render this page at most once every 10 minutes
export const revalidate = 600;
export default async function PricingPage() {
const res = await fetch("https://api.example.com/plans");
const plans: { id: string; name: string; price: number }[] = await res.json();
return (
<ul>
{plans.map((plan) => (
<li key={plan.id}>
{plan.name}: ${plan.price}/mo
</li>
))}
</ul>
);
}
A positive revalidate gives you time-based Incremental Static Regeneration: the page is served from the Full Route Cache and regenerated in the background once it's older than the interval. export const dynamic = "force-dynamic" does the opposite and opts the route out entirely.
How it's invalidated
The Full Route Cache is cleared in two ways:
- Revalidation. Time-based
revalidate, or on-demandrevalidatePath/revalidateTag. Revalidating data that a route depends on also invalidates that route's cached render, so the next request re-renders it with the fresh data. - A new deployment. Unlike the Data Cache, rendered routes are rebuilt on every deploy.
The Client Cache
The last layer lives in the browser. When a user navigates with Link or router.push, Next.js fetches the RSC payload for the new route and keeps it in memory. This is often called the router cache; the Next.js docs call it the client cache.
What it does by default:
- Layouts and loading states are reused across navigations, so moving between
/dashboard/aand/dashboard/bdoesn't refetch the shared dashboard layout. - Pages aren't reused on regular navigations by default. Visiting a page again fetches a fresh RSC payload.
- Back and forward navigation restores the cached payload, so the browser history feels instant and keeps the scroll position.
- Prefetched routes are kept for a short window so a hovered or visible link can open without a round trip.
The client cache is cleared when the user refreshes the page, when you call router.refresh(), and when a Server Action calls revalidatePath, revalidateTag, updateTag, or refresh, or sets or deletes a cookie.
If you want pages to be reused for a while on navigation, the experimental staleTimes option controls it:
// next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
experimental: {
staleTimes: {
dynamic: 30, // seconds; default 0
static: 180, // seconds; default 300
},
},
};
export default nextConfig;
dynamic applies to pages that aren't statically generated or fully prefetched; static applies to static pages and links prefetched with prefetch={true}.
How the Layers Interact
The layers are independent, which is exactly why they can surprise you. A few combinations worth knowing:
Data Cache and Full Route Cache. A static route built from cached fetch calls depends on both. Revalidating the data (with revalidateTag("posts", "max"), for example) invalidates the rendered route too. The reverse isn't true: opting a route into dynamic rendering doesn't stop its force-cache fetches from hitting the Data Cache. You can have a dynamic page full of cached data.
Memoization and the Data Cache. Memoization sits in front. Within a request, the first call checks the Data Cache (if the fetch opts in) and the result is memoized. Further identical calls never reach the Data Cache.
Server caches and the client cache. Revalidating on the server doesn't reach into browsers that already have a payload in memory. A Server Action that revalidates clears the client cache of the user who triggered it. Other users get fresh data the next time their browser asks the server for that route.
A debugging checklist
When a value looks stale, work through the layers from the outside in:
- Is it the browser? Do a hard refresh. If the value updates, it was the client cache.
- Is the route static? Check the build output for
○or●. If so, the whole page is cached until it's revalidated or redeployed. - Is the
fetchcached? Look forforce-cache,next.revalidate, orunstable_cache. Did you callrevalidateTagwith the same tag you assigned? - Are you looking in development?
next devrenders pages on every request, and it cachesfetchresponses across hot reloads, so it behaves differently from production. Test caching withnext build && next start.
Two settings help with step 3. logging.fetches.fullUrl in next.config.ts prints each fetch with its cache status during development:
// next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
logging: {
fetches: {
fullUrl: true,
},
},
};
export default nextConfig;
And setting NEXT_PRIVATE_DEBUG_CACHE=1 when running the server prints verbose logs about cache reads and writes.
How This Maps to Cache Components
Next.js 16 introduced Cache Components, enabled with cacheComponents: true in next.config.ts. It changes the defaults and the vocabulary, but the underlying ideas carry over:
| Previous model | With Cache Components |
|---|---|
| Request memoization | Unchanged: fetch memoization and React.cache still apply per request |
Data Cache (force-cache, unstable_cache) | "use cache" on functions, with cacheLife and cacheTag |
| Full Route Cache (whole route static or dynamic) | A static shell per route, with dynamic parts streamed in (Partial Prerendering) |
revalidate, dynamic, fetchCache segment configs | Replaced by "use cache", cacheLife, and Suspense boundaries |
Client cache tuned by staleTimes | Tuned per cached function by the stale value in cacheLife |
The biggest shift is that caching becomes explicit and granular. Nothing is cached unless you add "use cache", and a route is no longer all-static or all-dynamic: static and cached parts form a prerendered shell while request-specific parts stream in. That's covered in detail in the post on Partial Prerendering.
One practical difference is persistence. The fetch Data Cache and unstable_cache persist across deployments and serverless instances. By default, "use cache" entries live in memory, are scoped to a single deployment, and may not survive between serverless invocations, though they're still included in prerendered output. If you migrate and rely on long-lived cached data, look at "use cache: remote" or a custom cache handler.
Conclusion
Next.js caching makes sense once you stop treating it as a single switch. Request memoization deduplicates work within one render. The Data Cache keeps fetch results across requests, but only when you opt in. The Full Route Cache keeps whole rendered pages for routes that don't depend on the request. The client cache keeps payloads in the browser for fast navigation.
Each layer has its own lifetime and its own invalidation trigger, so when something looks stale, ask which layer is holding it. Check the build output, check your fetch options and tags, and test in a production build. And if you're starting fresh on Next.js 16, consider Cache Components, where the same ideas are expressed with "use cache", cacheLife, and cacheTag instead of implicit defaults.


