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 locationIn-memory LRU cache inside the running Next.js processRemote/distributed cache
Shared across server instancesNoYes
Survives instance restartNoUsually yes
Network lookup requiredNoYes
Extra infrastructure costNoneYes
Best forHot local reuseDurable/shared caching
Sensitive to cold startsVeryMuch less
Works well with Fluid ComputeYesYes
Default behaviorLocal opportunistic cachingDistributed cache layer
The really important distinction is that use cache is essentially “memoize this result inside this running server instance” while use cache: remote is “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 permutations

Closed-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:

SettingMeaning
staleHow long client navigation can treat data as fresh
revalidateWhen background refresh should occur
expireHard 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:

  1. Cold start
  2. Run function
  3. 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:

  1. Existing instance reused
  2. Many concurrent requests share a process
  3. 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.

© Karim Shehadeh
  • X
  • BlueSky
  • RSS
  • LinkedIn
  • StackOverflow
  • Github