How to Fix: Fetch calls inside “use cache” functions cannot be revalidated

5 min read

Revalidation breaks when a fetch() call lives inside a Next.js "use cache" function because you are combining two different caching layers that do not currently cooperate the way developers expect in canary builds. The result is a page that looks cached correctly at first, but calling revalidateTag, revalidatePath, or a custom purge action does not invalidate the nested fetch result.

This issue shows up in projects using the newer Cache Components model in Next.js 15 canary, where a function marked with "use cache" wraps logic that also performs a fetch with revalidation metadata. Even though the fetch may define tags or cache behavior, the outer cached function memoizes the full result boundary, so downstream invalidation does not always propagate back into that function.

Understanding the Root Cause

At a technical level, "use cache" and fetch caching are separate systems:

  • "use cache" caches the entire function result.
  • fetch() caching stores the network response and can optionally be connected to tags or revalidate rules.
  • revalidateTag() and related APIs target cache entries that were registered with the expected invalidation metadata.

The bug appears when the outer "use cache" function becomes the dominant cache boundary. If the function result is returned from cache, the inner fetch() may never execute again, which means its invalidated state is irrelevant until the outer function cache is also refreshed. In canary builds, that relationship is not consistently wired for nested revalidation scenarios.

In practice, the flow looks like this:

  1. A server component or helper function is marked with "use cache".
  2. That function calls fetch() using cache tags or revalidation options.
  3. The first request stores the full function output.
  4. A purge action triggers revalidateTag() or similar.
  5. The fetch cache may be invalidated, but the outer cached function output still resolves from cache.

That is why the UI appears stuck on stale data even though the revalidation call succeeded.

Step-by-Step Solution

The safest fix is to avoid placing revalidation-sensitive fetch calls inside a "use cache" function. Instead, choose one cache layer as the source of truth.

Recommended approach: move the fetch() outside the "use cache" boundary and let fetch tags control invalidation directly.

1. Problematic pattern

async function getProducts() {
  "use cache"

  const res = await fetch("https://example.com/api/products", {
    next: { tags: ["products"] }
  })

  return res.json()
}

This looks valid, but the outer function cache can block the inner fetch from rerunning after tag invalidation.

2. Preferred fix: cache the fetch, not the wrapper

export async function getProducts() {
  const res = await fetch("https://example.com/api/products", {
    next: { tags: ["products"] }
  })

  if (!res.ok) {
    throw new Error("Failed to fetch products")
  }

  return res.json()
}

Then trigger invalidation from a server action or route handler:

"use server"

import { revalidateTag } from "next/cache"

export async function purgeProducts() {
  revalidateTag("products")
}

This ensures the next render actually performs the fetch again.

3. If you must keep "use cache", do not nest revalidation-dependent fetches inside it

If a function truly benefits from "use cache", keep it limited to computation or data that does not rely on runtime revalidation.

async function fetchProducts() {
  const res = await fetch("https://example.com/api/products", {
    next: { tags: ["products"] }
  })

  if (!res.ok) {
    throw new Error("Failed to fetch products")
  }

  return res.json()
}

async function mapProductsView(products) {
  "use cache"

  return products.map((product) => ({
    id: product.id,
    label: product.name.toUpperCase()
  }))
}

export async function getProductView() {
  const products = await fetchProducts()
  return mapProductsView(products)
}

This pattern reduces the chance that the network layer becomes trapped behind an unrelated cache boundary.

4. Alternative workaround: use explicit no-store for highly dynamic data

If the data must always reflect the latest state immediately after mutation, disable fetch caching entirely.

export async function getProducts() {
  const res = await fetch("https://example.com/api/products", {
    cache: "no-store"
  })

  if (!res.ok) {
    throw new Error("Failed to fetch products")
  }

  return res.json()
}

This removes revalidation complexity, but you lose caching benefits.

5. Verify the fix locally

  1. Start the app in dev mode.
  2. Load the page that reads the cached data.
  3. Trigger the purge or revalidation action.
  4. Refresh or navigate again.
  5. Confirm the underlying fetch executes again and returns updated data.

If it still does not update, inspect whether another cache layer is involved, such as route-level caching, static rendering, or a client-side data library.

Common Edge Cases

  • Mixing "use cache" with tagged fetches: this is the exact failing combination in many canary cases. Prefer one invalidation strategy.
  • Using development mode as proof of production behavior: dev mode may bypass or alter parts of the caching pipeline. Always confirm in a production build too.
  • Calling revalidateTag with the wrong tag name: tags are exact-match identifiers. A mismatch silently looks like a cache bug.
  • Expecting client state to refresh automatically: server cache invalidation does not automatically reset local React state or third-party client caches.
  • Static route output: if the route or parent segment is statically optimized, you may be debugging the wrong cache layer.
  • Multiple nested cached helpers: even if you remove one "use cache", another wrapper higher up may still hold stale output.

FAQ

Does this mean "use cache" is broken?

Not generally. The issue is more specific: nested fetch revalidation inside a cached function is unreliable in this scenario, especially in Next.js canary builds. The feature works better when its boundaries are kept clear.

Should I use revalidatePath instead of revalidateTag?

Only if your invalidation model is route-oriented rather than data-oriented. revalidatePath refreshes route output, while revalidateTag targets tagged data dependencies. If the stale value is trapped inside a "use cache" function, switching APIs may not solve the underlying problem.

What is the best long-term pattern?

Use tagged fetches for remote data that needs invalidation, use "use cache" for stable computed results, and avoid stacking both around the same mutable data source until the framework behavior is fully stabilized.

For teams shipping now, the practical takeaway is simple: move the fetch out of the "use cache" function, let the fetch own revalidation, and treat the wrapper cache as a separate optimization layer only when the data is not mutation-sensitive.

Leave a Reply

Your email address will not be published. Required fields are marked *