The new cache model in Next.js is much more explicit than the older ISR/fetch-cache world. The important mental model is that "use cache" is not just caching fetches. It is caching the result of a server component or async function execution, including rendered RSC payloads and serialized return values.
use cache vs use cache: remote
| Behavior | "use cache" | "use cache: remote" |
|---|---|---|
| Storage location | In-memory LRU cache inside the running Next.js process | Remote/distributed cache |
| Shared across server instances | No | Yes |
| Survives instance restart | No | Usually yes |
| Network lookup required | No | Yes |
| Extra infrastructure cost | None | Yes |
| Best for | Hot local reuse | Durable/shared caching |
| Sensitive to cold starts | Very | Much less |
| Works well with Fluid Compute | Yes | Yes |
| Default behavior | Local opportunistic caching | Distributed cache layer |
The really important distinction is thatuse cacheis essentially “memoize this result inside this running server instance” whileuse cache: remoteis “store this cached result in shared infrastructure”. That difference completely changes the behavior at scale.
How Cache Keys Work
The cache key is effectively composed from a few inputs: build ID, function/component identity, serialized arguments, closed-over serializable values, and sometimes dev/HMR state.
So this:
async function getProduct(id: string) {
"use cache"
return db.products.find(id)
}
// Cache key becomes
// hash(
// buildId +
// functionId +
// serializedArgs(id)
// )
//
// getProduct("123") is different than getProduct("456")
But if you have a getProducts function that looks likes this, you can see that the cardinality can grow very quickly.
async function getProducts({ category: string, sort: string, page: number, filters: Record<string, string> } ) {
"use cache"
return db.products.find(...)
}
// and you call it like this:
getProducts({
category: "shoes",
sort: "price",
page: 8,
filters: [...]
})
// The serialized cache args cache key gets significantly more permutationsClosed-Over Variables and Hidden Cache Fragmentation
One of the more subtle aspects of "use cache" is that cache keys are not always built solely from the explicit function arguments. Variables captured from outer scope can also influence the cache key.
For example:
function createProductFetcher(region: string) {
return async function getProduct(id: string) {
"use cache"
return fetch(
`https://api.example.com/${region}/products/${id}`
).then(r => r.json())
}
}And you use it like this:
const usFetcher = createProductFetcher("us")
const euFetcher = createProductFetcher("eu")
// ....
usFetcher("123")
euFetcher("123")So even though usFetcher and euFetcher use the same function body and same argument ("123"), they produce different cache entries because region is closed over.
Conceptually the cache key becomes something like:
hash(
functionId +
args("123") +
closedOverValues("us")
)vs:
hash(
functionId +
args("123") +
closedOverValues("eu")
)That may seem obvious but this gets more subtle in component trees.
For example:
export async function ProductGrid({
locale,
currency
}) {
const formatter = new Intl.NumberFormat(locale, {
style: "currency",
currency
})
async function getProducts(category: string) {
"use cache"
const products = await fetchProducts(category)
return products.map(p => ({
...p,
formattedPrice: formatter.format(p.price)
}))
}
return getProducts("shoes")
}Here, formatter is closed over by the cached function.
That means locale and currency indirectly affect the cache key because they affect the captured formatter object. So these produce different cache entries: locale=en-US currency=USD vs locale=fr-FR currency=EUR even though getProducts("shoes") was called with the same explicit argument.
Accidental High Cardinality Through Closures
This becomes dangerous with accidental high cardinality. For example:
export async function SearchPage({ searchParams }) {
const userSession = await getSession()
async function getResults(query: string) {
"use cache"
return performSearch({
query,
userId: userSession.id
})
}
return getResults(searchParams.q)
}At first glance, it looks like the cache key is just query. But because userSession is closed over, every user effectively gets separate cache entries.
So the cache key becomes hash (query + userSession.id) and your cache cardinality explodes. That is one of the easiest ways teams accidentally destroy cache efficiency with "use cache" or "use cache: remote".
If a cached function references auth/session, cookies, headers, request state, timestamps, random values, personalization, or AB-test assignments, assume those values may implicitly fragment the cache unless you intentionally isolate them outside the cached scope.
High Cardinality Risks
This is probably the single biggest operational risk with the new cache system. High cardinality means: too many unique cache keys with too little reuse. Common sources include search queries, session/user IDs, and large filter combinations (often amplified by locale/currency and experimentation variants).
Here’s an example that you may want to avoid:
async function searchProducts(params) {
"use cache: remote"
return expensiveSearch(params)
}
/**
If params contains...
{
query,
sort,
page,
minPrice,
maxPrice,
color,
size,
sessionId
}
... you may end up with millions of unique keys.
*/The result is a terrible cache hit rate, excessive writes, exploding remote-cache costs, memory pressure, eviction churn, and almost no real performance benefit. This is why the docs explicitly warn that remote caching may not help when cache keys have mostly unique values per request.
At enterprise e-commerce scale, this matters a lot. For an e-commerce site, PLPs with faceting can explode cardinality; personalization compounds it; region/currency multiplies it; and experiments multiply it again. You can accidentally create a distributed cache that behaves like a write amplification machine.
cacheTag() and Invalidation
cacheTag() attaches invalidation metadata to entries. For example,
async function getProduct(id: string) {
"use cache"
cacheTag("products")
cacheTag(`product-${id}`)
return db.products.find(id)
}
// Now the cache entry can later be invalidated with:
revalidateTag("product-123")
// or
revalidateTag("products")This is much more powerful than old ISR because invalidation becomes semantic, scoped, and composable instead of: route based or TTL only.
cacheLife() Behavior
cacheLife() controls freshness windows. The typical structure is something like
cacheLife({
stale: 300,
revalidate: 900,
expire: 3600
})…where:
| Setting | Meaning |
|---|---|
stale | How long client navigation can treat data as fresh |
revalidate | When background refresh should occur |
expire | Hard maximum lifetime |
IMPORTANT: Nested cached functions interact. Inner cache scopes can influence outer scopes unless the outer scope explicitly overrides with its own cacheLife(). That can create surprising behavior in large trees.Fluid Compute vs Traditional Serverless
This is where "use cache" becomes much more interesting.
Traditional Serverless
Classic serverless behavior when a request is received is:
- Cold start
- Run function
- Instance dies shortly after.
The consequences are that the memory cache dies often, there’s low reuse and “use cache” becomes probabilistic and cold starts reduce their effectiveness
So "use cache" historically behaved more like a maybe helpful paradigm
Fluid Compute
Fluid Compute changes the model substantially.
Vercel describes it as “prioritizing reuse of existing resources, extending invocation lifetime, and enabling more concurrency per instance.”
Practical effect when a request is received:
- Existing instance reused
- Many concurrent requests share a process
- Instance survives longer
The consequences are that the in-memory cache survives longer, more requests hit the same cache, there are fewer cold starts, and "use cache" becomes materially more effective.
With Fluid Compute, rather than "use cache" being disposable serverless state, it behaves more like lightweight application-server memory caching: process-level memoization and hot-path acceleration.
Operational Implications
use cache
Good for nav data, CMS content, configuration, expensive transforms, hot shared content, and repeated RSC render work. Bad for personalized data, giant search combinations, infinite filters, and session-bound content.
use cache: remote
Good for shared data across instances, durable caching, reducing upstream API/database pressure, expensive CMS/API fetches, and globally reused content. Bad for high cardinality keys, rapidly changing user-specific data, cheap operations, and ultra-low-latency paths where remote lookup cost outweighs benefit.
And unlike plain "use cache", remote caching introduces network latency, storage costs, and write amplification risk.
Final Thoughts
My overall read is:
"use cache" + Fluid Compute is now surprisingly viable for many hot shared workloads. "use cache: remote" should be reserved for data with strong reuse characteristics and meaningful upstream cost savings. High-cardinality ecommerce search/filter scenarios are where teams can accidentally create very expensive, low-value caching systems. The biggest architectural mistake teams make is caching things that are technically cacheable but operationally have almost no reuse.
The most successful use of these APIs tends to be shared, semi-static, expensive-to-generate, heavily reused, and invalidatable by semantic tags. This is in contrast to highly personalized or combinatorial data.